diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..88c989193 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +# Dependencies (npm ci installs fresh inside the container) +node_modules +**/node_modules + +# Build artifacts (rebuilt from scratch inside the container) +dist +**/dist + +# Version control +.git diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c410b6cdd..3608d961b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,23 @@ jobs: - name: 'Run sensitive keyword linter' run: 'node scripts/lint.js --sensitive-keywords' + - name: 'Build CLI package' + run: 'npm run build --workspace=packages/cli' + + - name: 'Generate settings schema' + run: 'npm run generate:settings-schema' + + - name: 'Check settings schema is up-to-date' + run: | + if [[ -n $(git status --porcelain packages/vscode-ide-companion/schemas/settings.schema.json) ]]; then + echo "❌ Error: settings.schema.json is out of date!" + echo " Please run: npm run generate:settings-schema" + echo " Then commit the updated schema file." + git diff packages/vscode-ide-companion/schemas/settings.schema.json + exit 1 + fi + echo "✅ Settings schema is up-to-date" + # # Test: Node # diff --git a/.gitignore b/.gitignore index cd7c11a11..27e0ab904 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ packages/*/coverage/ # Generated files packages/cli/src/generated/ packages/core/src/generated/ +packages/web-templates/src/generated/ .integration-tests/ packages/vscode-ide-companion/*.vsix diff --git a/.prettierignore b/.prettierignore index f4330b7e6..5e9d79005 100644 --- a/.prettierignore +++ b/.prettierignore @@ -18,3 +18,5 @@ eslint.config.js gha-creds-*.json junit.xml Thumbs.db +packages/vscode-ide-companion/schemas/settings.schema.json +packages/cli/src/services/insight/templates/insightTemplate.ts diff --git a/.qwen/commands/qc/code-review.md b/.qwen/commands/qc/code-review.md new file mode 100644 index 000000000..b5846485a --- /dev/null +++ b/.qwen/commands/qc/code-review.md @@ -0,0 +1,25 @@ +--- +description: Code review a pull request +--- + +You are an expert code reviewer. Follow these steps: + +1. If no PR number is provided in the args, use Bash(\"gh pr list\") to show open PRs +2. If a PR number is provided, use Bash(\"gh pr view \") to get PR details +3. Use Bash(\"gh pr diff \") to get the diff +4. Analyze the changes and provide a thorough code review that includes: + - Overview of what the PR does + - Analysis of code quality and style + - Specific suggestions for improvements + - Any potential issues or risks + +Keep your review concise but thorough. Focus on: +- Code correctness +- Following project conventions +- Performance implications +- Test coverage +- Security considerations + +Format your review with clear sections and bullet points. + +PR number: {{args}} diff --git a/.qwen/commands/qc/commit.md b/.qwen/commands/qc/commit.md new file mode 100644 index 000000000..76ef6b417 --- /dev/null +++ b/.qwen/commands/qc/commit.md @@ -0,0 +1,70 @@ +--- +description: Commit staged changes with an AI-generated commit message and push +--- + +# Commit and Push + +## Overview +Generate a clear, concise commit message based on staged changes, confirm with the user, then commit and push. + +## Steps + +### 1. Check repository status +- Run `git status` to check: + - Are there any staged changes? + - Are there unstaged changes? + - What is the current branch? + +### 2. Handle unstaged changes +- If there are unstaged changes, notify the user and list them +- Do NOT add or commit unstaged changes +- Proceed only with staged changes + +### 3. Review staged changes +- Run `git diff --staged` to see all staged changes +- Analyze the changes in depth to understand: + - What files were modified/added/deleted + - The nature of the changes (feature, fix, refactor, docs, etc.) + - The scope and impact of the changes + +### 4. Handle branch logic +- Get current branch name with `git branch --show-current` +- **If current branch is `main` or `master`:** + - Generate a proper branch name based on the changes + - Create and switch to the new branch: `git checkout -b ` +- **If current branch is NOT main/master:** + - Check if branch name matches the staged changes + - If branch name doesn't match changes, ask user: + - "Current branch `` doesn't seem to match these changes." + - "Options: (1) Create and switch to a new branch, (2) Commit directly on current branch" + - Wait for user decision + +### 5. Generate commit message +- Types: feat, fix, docs, style, refactor, test, chore +- Guidelines: + - Be clear and concise + - Reference issues if mentioned in changes + - Include scope in parentheses when applicable (e.g., `fix(insight):`, `feat(auth):`) + - Add bullet points for detailed changes if it addes more value, otherwise do not use bullets + - Include a footer explaining the purpose/impact of the changes + +**Format:** +``` +(): +- (optional) +- (optional) +- ... + +This . +``` + +### 6. Present the result and confirm with user +- Present the generated commit message +- Show which branch will be used +- Ask for confirmation: "Proceed with commit and push?" +- Wait for user approval + +### 7. Commit and push +- After user confirms: + - `git commit -m ""` + - `git push -u origin ` (use `-u` for new branches) diff --git a/.qwen/commands/qc/create-issue.md b/.qwen/commands/qc/create-issue.md new file mode 100644 index 000000000..54317621b --- /dev/null +++ b/.qwen/commands/qc/create-issue.md @@ -0,0 +1,42 @@ +--- +description: Draft and submit a GitHub issue based on a user-provided idea +--- + +# Create Issue + +## Overview +Take the user's idea or bug description, investigate the codebase to understand the full context, draft a GitHub issue for review, and submit it once approved. + +## Input +The user provides a brief description of a feature request or bug report: {{args}} + +## Steps + +1. **Understand the request** + - Read the user's description carefully + - Determine whether this is a feature request or a bug report + +2. **Investigate the codebase** + - Search for relevant code, files, and existing behavior related to the request + - Build a thorough understanding of how the current system works + - Identify any related issues or prior art if mentioned + +3. **Draft the issue** + - Write a markdown file for the user to review + - Use the appropriate template: + - Feature request: follow @.github/ISSUE_TEMPLATE/feature_request.yml + - Bug report: follow @.github/ISSUE_TEMPLATE/bug_report.yml + - Write from the user's perspective, not as an implementation spec + - Keep the language clear and concise, AVOID internal implementation details + +4. **Review with user** + - Present the draft file to the user + - Iterate on feedback until the user is satisfied + - Do NOT submit until the user explicitly asks to + +5. **Submit the issue** + - When the user confirms, create the issue using `gh issue create` + - Apply the appropriate labels: + - Feature request: `type/feature-request`, `status/needs-triage` + - Bug report: `type/bug`, `status/needs-triage` + - Report back the issue URL diff --git a/.qwen/commands/qc/create-pr.md b/.qwen/commands/qc/create-pr.md new file mode 100644 index 000000000..bf3c3c1e4 --- /dev/null +++ b/.qwen/commands/qc/create-pr.md @@ -0,0 +1,34 @@ +--- +description: Create a pull request based on staged code changes +--- + +# Create PR + +## Overview +Create a well-structured pull request with proper description and title. + +## Steps +1. **Review staged changes** + - Review all staged changes to understand what has been done + - Do not touch unstaged changes + +2. **Prepare branch** + - Create a new branch with proper name if current branch is main + - Ensure all changes are committed + - Push branch to remote + +3. **Write PR description** + - Use PR Template below + - Summarize changes clearly + - Include context and motivation + - List any breaking changes + - Link related issues if provided, or use "No linked issues" + - Add this line at the end of PR body: "🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)", with a line separator + +4. **Set up PR** + - Create PR title and body + - Submit PR with gh command + +## PR Template + +@{.github/pull_request_template.md} \ No newline at end of file diff --git a/.qwen/skills/terminal-capture/SKILL.md b/.qwen/skills/terminal-capture/SKILL.md index adf8fff13..7fc99a18d 100644 --- a/.qwen/skills/terminal-capture/SKILL.md +++ b/.qwen/skills/terminal-capture/SKILL.md @@ -109,6 +109,38 @@ Supported key names: `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Enter`, Auto-screenshot is triggered after the key sequence ends (when the next step is not a `key`). +### `streaming` — Capture During Execution + +Capture multiple screenshots at intervals during long-running output (e.g., progress bars). Optionally generates an animated GIF. + +```typescript +{ + type: 'Run this command: bash progress.sh', + streaming: { + delayMs: 7000, // Wait before first capture (skip initial waiting phase) + intervalMs: 500, // Interval between captures + count: 20, // Maximum number of captures + gif: true, // Generate animated GIF (default: true, requires ffmpeg) + }, +} +``` + +- `delayMs` (optional): Milliseconds to wait after pressing Enter before starting captures. Useful for skipping model thinking/approval time. +- Captures stop early if terminal output is unchanged for 3 consecutive intervals. +- Duplicate frames (no output change) are automatically skipped. + +**GIF prerequisite**: If the scenario uses `streaming` with GIF enabled (default), check if `ffmpeg` is installed before running. If not, ask the user whether they'd like to install it: + +```bash +# Check +which ffmpeg + +# Install (macOS) +brew install ffmpeg +``` + +If the user declines, the scenario still runs — GIF generation is skipped with a warning. + ### `capture` / `captureFull` — Explicit Screenshot Use as a standalone step, or override automatic naming: @@ -178,20 +210,32 @@ This tool is commonly used for visual verification during PR reviews. For the co ## Full ScenarioConfig Type ```typescript -interface ScenarioConfig { - name: string; // Scenario name (also used as screenshot subdirectory name) - spawn: string[]; // Launch command ["node", "dist/cli.js", "--yolo"] - flow: FlowStep[]; // Interaction steps - terminal?: { - // Terminal configuration (all optional) - cols?: number; // Number of columns, default 100 - rows?: number; // Number of rows, default 28 - theme?: string; // Theme: dracula|one-dark|github-dark|monokai|night-owl - chrome?: boolean; // macOS window decorations, default true - title?: string; // Window title, default "Terminal" - fontSize?: number; // Font size - cwd?: string; // Working directory (relative to config file) +interface FlowStep { + type?: string; // Input text + key?: string | string[]; // Key press(es) + capture?: string; // Viewport screenshot filename + captureFull?: string; // Full scrollback screenshot filename + streaming?: { + delayMs?: number; // Delay before first capture (default: 0) + intervalMs: number; // Interval between captures in ms + count: number; // Maximum number of captures + gif?: boolean; // Generate animated GIF (default: true) }; - outputDir?: string; // Screenshot output directory (relative to config file) +} + +interface ScenarioConfig { + name: string; // Scenario name (also used as screenshot subdirectory name) + spawn: string[]; // Launch command ["node", "dist/cli.js", "--yolo"] + flow: FlowStep[]; // Interaction steps + terminal?: { + cols?: number; // Number of columns, default 100 + rows?: number; // Number of rows, default 28 + theme?: string; // Theme: dracula|one-dark|github-dark|monokai|night-owl + chrome?: boolean; // macOS window decorations, default true + title?: string; // Window title, default "Terminal" + fontSize?: number; // Font size + cwd?: string; // Working directory (relative to config file) + }; + outputDir?: string; // Screenshot output directory (relative to config file) } ``` diff --git a/Dockerfile b/Dockerfile index 52a4d4416..6d30d6ad3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,12 +19,12 @@ ENV PATH=$PATH:/usr/local/share/npm-global/bin COPY . /home/node/app WORKDIR /home/node/app -# Install dependencies and build packages -# Use scripts/build.js which handles workspace dependencies in correct order +# Install dependencies, build workspaces, bundle into a single distributable, and pack RUN npm ci \ && npm run build \ - && npm pack -w @qwen-code/qwen-code --pack-destination ./packages/cli/dist \ - && npm pack -w @qwen-code/qwen-code-core --pack-destination ./packages/core/dist + && npm run bundle \ + && npm run prepare:package \ + && cd dist && npm pack # Runtime stage FROM docker.io/library/node:20-slim @@ -61,9 +61,8 @@ RUN mkdir -p /usr/local/share/npm-global ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global ENV PATH=$PATH:/usr/local/share/npm-global/bin -# Copy built packages from builder stage -COPY --from=builder /home/node/app/packages/cli/dist/*.tgz /tmp/ -COPY --from=builder /home/node/app/packages/core/dist/*.tgz /tmp/ +# Copy bundled package from builder stage +COPY --from=builder /home/node/app/dist/*.tgz /tmp/ # Install built packages globally RUN npm install -g /tmp/*.tgz \ diff --git a/docs/developers/roadmap.md b/docs/developers/roadmap.md index 83cd42355..b1f30199c 100644 --- a/docs/developers/roadmap.md +++ b/docs/developers/roadmap.md @@ -18,7 +18,7 @@ | Feature | Version | Description | Category | Phase | | ----------------------- | --------- | ------------------------------------------------------- | ------------------------------- | ----- | -| **Coding Plan** | `V0.10.0` | Bailian Coding Plan authentication & models | User Experience | 2 | +| **Coding Plan** | `V0.10.0` | Alibaba Cloud Coding Plan authentication & models | User Experience | 2 | | Unified WebUI | `V0.9.0` | Shared WebUI component library for VSCode/CLI | User Experience | 2 | | Export Chat | `V0.8.0` | Export sessions to Markdown/HTML/JSON/JSONL | User Experience | 2 | | Extension System | `V0.8.0` | Full extension management with slash commands | Building Open Capabilities | 2 | diff --git a/docs/developers/tools/sandbox.md b/docs/developers/tools/sandbox.md index 92550f164..de3e23716 100644 --- a/docs/developers/tools/sandbox.md +++ b/docs/developers/tools/sandbox.md @@ -38,6 +38,7 @@ ls -la $(dirname $(which qwen))/../lib/node_modules/@qwen-code/qwen-code # 7.Test the version of qwen qwen -v # npm link will overwrite the global qwen. To avoid being unable to distinguish the same version number, you can uninstall the global CLI first + ``` #### 3、Create your sandbox Dockerfile under the root directory of your own project diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index 1d5d30240..3e15aa462 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -1,13 +1,12 @@ # Authentication -Qwen Code supports two authentication methods. Pick the one that matches how you want to run the CLI: +Qwen Code supports three 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. -- **API-KEY**: use an API key to connect to any supported provider. More flexible — supports OpenAI, Anthropic, Google GenAI, Alibaba Cloud Bailian, and other compatible endpoints. +- **Qwen OAuth**: sign in with your `qwen.ai` account in a browser. Free with a daily quota. +- **Alibaba Cloud Coding Plan**: use an API key from Alibaba Cloud. Paid subscription with diverse model options and higher quotas. +- **API Key**: bring your own API key. Flexible to your own needs — supports OpenAI, Anthropic, Gemini, and other compatible endpoints. -![](https://gw.alicdn.com/imgextra/i4/O1CN01yXSXc91uYxJxhJXBF_!!6000000006050-2-tps-2372-916.png) - -## 👍 Option 1: Qwen OAuth (recommended & free) +## Option 1: Qwen OAuth (Free) Use this if you want the simplest setup and you're using Qwen models. @@ -25,15 +24,72 @@ qwen > [!note] > > In non-interactive or headless environments (e.g., CI, SSH, containers), you typically **cannot** complete the OAuth browser login flow. -> In these cases, please use the API-KEY authentication method. +> In these cases, please use the Alibaba Cloud Coding Plan or API Key authentication method. -## 🚀 Option 2: API-KEY (flexible) +## 💳 Option 2: Alibaba Cloud Coding Plan -Use this if you want more flexibility over which provider and model to use. Supports multiple protocols and providers, including OpenAI, Anthropic, Google GenAI, Alibaba Cloud Bailian, Azure OpenAI, OpenRouter, ModelScope, or a self-hosted compatible endpoint. +Use this if you want predictable costs with diverse model options and higher usage quotas. + +- **How it works**: Subscribe to the Coding Plan with a fixed monthly fee, then configure Qwen Code to use the dedicated endpoint and your subscription API key. +- **Requirements**: Obtain an active Coding Plan subscription from [Aliyun Bailian](https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan) or [Alibaba Cloud](https://bailian.console.alibabacloud.com/?tab=model#/efm/coding_plan), depending on the region of your account. +- **Benefits**: Diverse model options, higher usage quotas, predictable monthly costs, access to a wide range of models (Qwen, GLM, Kimi, Minimax and more). +- **Cost & quota**: View [Aliyun Bailian Coding Plan documentation](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3005961). + +Alibaba Cloud Coding Plan is available in two regions: + +| Region | Console URL | +| -------------------------------- | ---------------------------------------------------------------------------- | +| Aliyun Bailian (aliyun.com) | [bailian.console.aliyun.com](https://bailian.console.aliyun.com) | +| Alibaba Cloud (alibabacloud.com) | [bailian.console.alibabacloud.com](https://bailian.console.alibabacloud.com) | + +### Interactive setup + +Enter `qwen` in the terminal to launch Qwen Code, then run the `/auth` command and select **Alibaba Cloud Coding Plan**. Choose your region, then enter your `sk-sp-xxxxxxxxx` key. + +After authentication, use the `/model` command to switch between all Alibaba Cloud Coding Plan supported models (including qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, qwen3-max, glm-4.7, and kimi-k2.5). + +### Alternative: configure via `settings.json` + +If you prefer to skip the interactive `/auth` flow, add the following to `~/.qwen/settings.json`: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3-coder-plus", + "name": "qwen3-coder-plus (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "qwen3-coder-plus from Alibaba Cloud Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY" + } + ] + }, + "env": { + "BAILIAN_CODING_PLAN_API_KEY": "sk-sp-xxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3-coder-plus" + } +} +``` + +> [!note] +> +> The Coding Plan uses a dedicated endpoint (`https://coding.dashscope.aliyuncs.com/v1`) that is different from the standard Dashscope endpoint. Make sure to use the correct `baseUrl`. + +## 🚀 Option 3: API Key (flexible) + +Use this if you want to connect to third-party providers such as OpenAI, Anthropic, Google, Azure OpenAI, OpenRouter, ModelScope, or a self-hosted endpoint. Supports multiple protocols and providers. ### Recommended: One-file setup via `settings.json` -The simplest way to get started with API-KEY authentication is to put everything in a single `~/.qwen/settings.json` file. Here's a complete, ready-to-use example: +The simplest way to get started with API Key authentication is to put everything in a single `~/.qwen/settings.json` file. Here's a complete, ready-to-use example: ```json { @@ -66,7 +122,7 @@ What each field does: | Field | Description | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `modelProviders` | Declares which models are available and how to connect to them. Keys (`openai`, `anthropic`, `gemini`, `vertex-ai`) represent the API protocol. | +| `modelProviders` | Declares which models are available and how to connect to them. Keys (`openai`, `anthropic`, `gemini`) represent the API protocol. | | `env` | Stores API keys directly in `settings.json` as a fallback (lowest priority — shell `export` and `.env` files take precedence). | | `security.auth.selectedType` | Tells Qwen Code which protocol to use on startup (e.g. `openai`, `anthropic`, `gemini`). Without this, you'd need to run `/auth` interactively. | | `model.name` | The default model to activate when Qwen Code starts. Must match one of the `id` values in your `modelProviders`. | @@ -77,76 +133,15 @@ After saving the file, just run `qwen` — no interactive `/auth` setup needed. > > The sections below explain each part in more detail. If the quick example above works for you, feel free to skip ahead to [Security notes](#security-notes). -### Option1: Coding Plan(Aliyun Bailian) - -Use this if you want predictable costs with higher usage quotas for the qwen3-coder-plus model. - -- **How it works**: Subscribe to the Coding Plan with a fixed monthly fee, then configure Qwen Code to use the dedicated endpoint and your subscription API key. -- **Requirements**: Obtain an active Coding Plan subscription from [Alibaba Cloud Bailian](https://bailian.console.aliyun.com/cn-beijing/?tab=globalset#/efm/coding_plan). -- **Benefits**: Higher usage quotas, predictable monthly costs, access to the latest qwen3-coder-plus model. -- **Cost & quota**: View [Alibaba Cloud Bailian Coding Plan documentation](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3005961). - -Enter `qwen` in the terminal to launch Qwen Code, then enter the `/auth` command and select `API-KEY` - -![](https://gw.alicdn.com/imgextra/i4/O1CN01yXSXc91uYxJxhJXBF_!!6000000006050-2-tps-2372-916.png) - -After entering, select `Coding Plan`: - -![](https://gw.alicdn.com/imgextra/i4/O1CN01Irk0AD1ebfop69o0r_!!6000000003890-2-tps-2308-830.png) - -Enter your `sk-sp-xxxxxxxxx` key, then use the `/model` command to switch between all Bailian `Coding Plan` supported models (including qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, qwen3-max, glm-4.7, and kimi-k2.5): - -![](https://gw.alicdn.com/imgextra/i4/O1CN01fWArmf1kaCEgSmPln_!!6000000004699-2-tps-2304-1374.png) - -**Alternative: configure Coding Plan via `settings.json`** - -If you prefer to skip the interactive `/auth` flow, add the following to `~/.qwen/settings.json`: - -```json -{ - "modelProviders": { - "openai": [ - { - "id": "qwen3-coder-plus", - "name": "qwen3-coder-plus (Coding Plan)", - "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", - "description": "qwen3-coder-plus from Bailian Coding Plan", - "envKey": "BAILIAN_CODING_PLAN_API_KEY" - } - ] - }, - "env": { - "BAILIAN_CODING_PLAN_API_KEY": "sk-sp-xxxxxxxxx" - }, - "security": { - "auth": { - "selectedType": "openai" - } - }, - "model": { - "name": "qwen3-coder-plus" - } -} -``` - -> [!note] -> -> The Coding Plan uses a dedicated endpoint (`https://coding.dashscope.aliyuncs.com/v1`) that is different from the standard Dashscope endpoint. Make sure to use the correct `baseUrl`. - -### Option2: Third-party API-KEY - -Use this if you want to connect to third-party providers such as OpenAI, Anthropic, Google, Azure OpenAI, OpenRouter, ModelScope, or a self-hosted endpoint. - The key concept is **Model Providers** (`modelProviders`): Qwen Code supports multiple API protocols, not just OpenAI. You configure which providers and models are available by editing `~/.qwen/settings.json`, then switch between them at runtime with the `/model` command. #### Supported protocols -| Protocol | `modelProviders` key | Environment variables | Providers | -| ----------------- | -------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | -| OpenAI-compatible | `openai` | `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL` | OpenAI, Azure OpenAI, OpenRouter, ModelScope, Alibaba Cloud Bailian, any OpenAI-compatible endpoint | -| Anthropic | `anthropic` | `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` | Anthropic Claude | -| Google GenAI | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL` | Google Gemini | -| Google Vertex AI | `vertex-ai` | `GOOGLE_API_KEY`, `GOOGLE_MODEL` | Google Vertex AI | +| Protocol | `modelProviders` key | Environment variables | Providers | +| ----------------- | -------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | +| OpenAI-compatible | `openai` | `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL` | OpenAI, Azure OpenAI, OpenRouter, ModelScope, Alibaba Cloud, any OpenAI-compatible endpoint | +| Anthropic | `anthropic` | `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` | Anthropic Claude | +| Google GenAI | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL` | Google Gemini | #### Step 1: Configure models and providers in `~/.qwen/settings.json` @@ -266,12 +261,12 @@ This is the approach used in the [one-file setup example](#recommended-one-file- **Priority summary:** -| Priority | Source | Override behavior | -| ----------- | ------------------------------ | ---------------------------------------- | -| 1 (highest) | CLI flags (`--openai-api-key`) | Always wins | -| 2 | System env (`export`, inline) | Overrides `.env` and `settings.env` | -| 3 | `.env` file | Only sets if not in system env | -| 4 (lowest) | `settings.json` → `env` | Only sets if not in system env or `.env` | +| Priority | Source | Override behavior | +| ----------- | ------------------------------ | -------------------------------------------- | +| 1 (highest) | CLI flags (`--openai-api-key`) | Always wins | +| 2 | System env (`export`, inline) | Overrides `.env` and `settings.json` → `env` | +| 3 | `.env` file | Only sets if not in system env | +| 4 (lowest) | `settings.json` → `env` | Only sets if not in system env or `.env` | #### Step 3: Switch models with `/model` @@ -292,11 +287,11 @@ qwen --model "qwen3-coder-plus" # In another terminal -qwen --model "qwen3-coder-next" +qwen --model "qwen3.5-plus" ``` ## Security notes -- Don’t commit API keys to version control. +- 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/model-providers.md b/docs/users/configuration/model-providers.md index 2e6265917..bcfc2cc75 100644 --- a/docs/users/configuration/model-providers.md +++ b/docs/users/configuration/model-providers.md @@ -4,12 +4,14 @@ Qwen Code allows you to configure multiple model providers through the `modelPro ## Overview -Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, `vertex-ai`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden. +Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden. > [!note] -> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows. +> +> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, etc., must be defined via `modelProviders`. The `/auth` command lists Qwen OAuth, Alibaba Cloud Coding Plan, and API Key as the built-in authentication options. > [!warning] +> > **Duplicate model IDs within the same authType:** Defining multiple models with the same `id` under a single `authType` (e.g., two entries with `"id": "gpt-4o"` in `openai`) is currently not supported. If duplicates exist, **the first occurrence wins** and subsequent duplicates are skipped with a warning. Note that the `id` field is used both as the configuration identifier and as the actual model name sent to the API, so using unique IDs (e.g., `gpt-4o-creative`, `gpt-4o-balanced`) is not a viable workaround. This is a known limitation that we plan to address in a future release. ## Configuration Examples by Auth Type @@ -25,7 +27,6 @@ The `modelProviders` object keys must be valid `authType` values. Currently supp | `openai` | OpenAI-compatible APIs (OpenAI, Azure OpenAI, local inference servers like vLLM/Ollama) | | `anthropic` | Anthropic Claude API | | `gemini` | Google Gemini API | -| `vertex-ai` | Google Vertex AI | | `qwen-oauth` | Qwen OAuth (hard-coded, cannot be overridden in `modelProviders`) | > [!warning] @@ -35,12 +36,12 @@ The `modelProviders` object keys must be valid `authType` values. Currently supp Qwen Code uses the following official SDKs to send requests to each provider: -| Auth Type | SDK Package | -| ---------------------- | ----------------------------------------------------------------------------------------------- | -| `openai` | [`openai`](https://www.npmjs.com/package/openai) - Official OpenAI Node.js SDK | -| `anthropic` | [`@anthropic-ai/sdk`](https://www.npmjs.com/package/@anthropic-ai/sdk) - Official Anthropic SDK | -| `gemini` / `vertex-ai` | [`@google/genai`](https://www.npmjs.com/package/@google/genai) - Official Google GenAI SDK | -| `qwen-oauth` | [`openai`](https://www.npmjs.com/package/openai) with custom provider (DashScope-compatible) | +| Auth Type | SDK Package | +| ------------ | ----------------------------------------------------------------------------------------------- | +| `openai` | [`openai`](https://www.npmjs.com/package/openai) - Official OpenAI Node.js SDK | +| `anthropic` | [`@anthropic-ai/sdk`](https://www.npmjs.com/package/@anthropic-ai/sdk) - Official Anthropic SDK | +| `gemini` | [`@google/genai`](https://www.npmjs.com/package/@google/genai) - Official Google GenAI SDK | +| `qwen-oauth` | [`openai`](https://www.npmjs.com/package/openai) with custom provider (DashScope-compatible) | This means the `baseUrl` you configure should be compatible with the corresponding SDK's expected API format. For example, when using `openai` auth type, the endpoint must accept OpenAI API format requests. @@ -62,6 +63,9 @@ This auth type supports not only OpenAI's official API but also any OpenAI-compa "maxRetries": 3, "enableCacheControl": true, "contextWindowSize": 128000, + "modalities": { + "image": true + }, "customHeaders": { "X-Client-Request-ID": "req-123" }, @@ -181,31 +185,6 @@ This auth type supports not only OpenAI's official API but also any OpenAI-compa } ``` -### Google Vertex AI (`vertex-ai`) - -```json -{ - "modelProviders": { - "vertex-ai": [ - { - "id": "gemini-1.5-pro-vertex", - "name": "Gemini 1.5 Pro (Vertex AI)", - "envKey": "GOOGLE_API_KEY", - "baseUrl": "https://generativelanguage.googleapis.com", - "generationConfig": { - "timeout": 90000, - "contextWindowSize": 2000000, - "samplingParams": { - "temperature": 0.2, - "max_tokens": 8192 - } - } - } - ] - } -} -``` - ### Local Self-Hosted Models (via OpenAI-compatible API) Most local inference servers (vLLM, Ollama, LM Studio, etc.) provide an OpenAI-compatible API endpoint. Configure them using the `openai` auth type with a local `baseUrl`: @@ -273,15 +252,16 @@ export VLLM_API_KEY="not-needed" ``` > [!note] -> The `extra_body` parameter is **only supported for OpenAI-compatible providers** (`openai`, `qwen-oauth`). It is ignored for Anthropic, Gemini, and Vertex AI providers. +> +> The `extra_body` parameter is **only supported for OpenAI-compatible providers** (`openai`, `qwen-oauth`). It is ignored for Anthropic, and Gemini providers. -## Bailian Coding Plan +## Alibaba Cloud Coding Plan -Bailian Coding Plan provides a pre-configured set of Qwen models optimized for coding tasks. This feature is available for users with Bailian API access and offers a simplified setup experience with automatic model configuration updates. +Alibaba Cloud Coding Plan provides a pre-configured set of Qwen models optimized for coding tasks. This feature is available for users with Alibaba Cloud Coding Plan API access and offers a simplified setup experience with automatic model configuration updates. ### Overview -When you authenticate with a Bailian Coding Plan API key using the `/auth` command, Qwen Code automatically configures the following models: +When you authenticate with an Alibaba Cloud Coding Plan API key using the `/auth` command, Qwen Code automatically configures the following models: | Model ID | Name | Description | | ---------------------- | -------------------- | -------------------------------------- | @@ -291,19 +271,19 @@ When you authenticate with a Bailian Coding Plan API key using the `/auth` comma ### Setup -1. Obtain a Bailian Coding Plan API key: +1. Obtain an Alibaba Cloud Coding Plan API key: - **China**: - **International**: 2. Run the `/auth` command in Qwen Code -3. Select the API-KEY authentication method -4. Select your region (China or Global/International) +3. Select **Alibaba Cloud Coding Plan** +4. Select your region 5. Enter your API key when prompted The models will be automatically configured and added to your `/model` picker. ### Regions -Bailian Coding Plan supports two regions: +Alibaba Cloud Coding Plan supports two regions: | Region | Endpoint | Description | | -------------------- | ----------------------------------------------- | ----------------------- | @@ -314,9 +294,10 @@ The region is selected during authentication and stored in `settings.json` under ### API Key Storage -When you configure Coding Plan through the `/auth` command, the API key is stored using the reserved environment variable name `BAILIAN_CODING_PLAN_API_KEY`. By default, it is stored in the `settings.env` field of your `settings.json` file. +When you configure Coding Plan through the `/auth` command, the API key is stored using the reserved environment variable name `BAILIAN_CODING_PLAN_API_KEY`. By default, it is stored in the `env` field of your `settings.json` file. > [!warning] +> > **Security Recommendation**: For better security, it is recommended to move the API key from `settings.json` to a separate `.env` file and load it as an environment variable. For example: > > ```bash @@ -347,7 +328,7 @@ If you prefer to manually configure Coding Plan models, you can add them to your { "id": "qwen3-coder-plus", "name": "qwen3-coder-plus", - "description": "Qwen3-Coder via Bailian Coding Plan", + "description": "Qwen3-Coder via Alibaba Cloud Coding Plan", "envKey": "YOUR_CUSTOM_ENV_KEY", "baseUrl": "https://coding.dashscope.aliyuncs.com/v1" } @@ -357,13 +338,15 @@ If you prefer to manually configure Coding Plan models, you can add them to your ``` > [!note] +> > When using manual configuration: - +> > - You can use any environment variable name for `envKey` > - You do not need to configure `codingPlan.*` > - **Automatic updates will not apply** to manually configured Coding Plan models > [!warning] +> > If you also use automatic Coding Plan configuration, automatic updates may overwrite your manual configurations if they use the same `envKey` and `baseUrl` as the automatic configuration. To avoid this, ensure your manual configuration uses a different `envKey` if possible. ## Resolution Layers and Atomicity @@ -382,6 +365,7 @@ The effective auth/model/credential values are chosen per field using the follow \*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration. > [!warning] +> > **Deprecation of `security.auth.apiKey` and `security.auth.baseUrl`:** Directly configuring API credentials via `security.auth.apiKey` and `security.auth.baseUrl` in `settings.json` is deprecated. These settings were used in historical versions for credentials entered through the UI, but the credential input flow was removed in version 0.10.1. These fields will be fully removed in a future release. **It is strongly recommended to migrate to `modelProviders`** for all model and credential configurations. Use `envKey` in `modelProviders` to reference environment variables for secure credential management instead of hardcoding credentials in settings files. ## Generation Config Layering: The Impermeable Provider Layer @@ -515,6 +499,7 @@ The snapshot: ## Selection Persistence and Recommendations > [!important] +> > Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope. - `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog. diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 82db2b319..edca4aedd 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -2,7 +2,7 @@ > [!tip] > -> **Authentication / API keys:** Authentication (Qwen OAuth vs OpenAI-compatible API) and auth-related environment variables (like `OPENAI_API_KEY`) are documented in **[Authentication](../configuration/auth)**. +> **Authentication / API keys:** Authentication (Qwen OAuth, Alibaba Cloud Coding Plan, or API Key) and auth-related environment variables (like `OPENAI_API_KEY`) are documented in **[Authentication](../configuration/auth)**. > [!note] > @@ -125,18 +125,18 @@ Settings are organized into categories. All settings should be placed within the #### 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`, `enableCacheControl`, `contextWindowSize` (override model's context window size), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), 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` | +| 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`, `enableCacheControl`, `contextWindowSize` (override model's context window size), `modalities` (override auto-detected input modalities), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), 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:** @@ -146,6 +146,9 @@ Settings are organized into categories. All settings should be placed within the "generationConfig": { "timeout": 60000, "contextWindowSize": 128000, + "modalities": { + "image": true + }, "enableCacheControl": true, "customHeaders": { "X-Client-Request-ID": "req-123" @@ -167,6 +170,10 @@ Settings are organized into categories. All settings should be placed within the Overrides the default context window size for the selected model. Qwen Code determines the context window using built-in defaults based on model name matching, with a constant fallback value. Use this setting when a provider's effective context limit differs from Qwen Code's default. This value defines the model's assumed maximum context capacity, not a per-request token limit. +**modalities:** + +Overrides the auto-detected input modalities for the selected model. Qwen Code automatically detects supported modalities (image, PDF, audio, video) based on model name pattern matching. Use this setting when the auto-detection is incorrect — for example, to enable `pdf` for a model that supports it but isn't recognized. Format: `{ "image": true, "pdf": true, "audio": true, "video": true }`. Omit a key or set it to `false` for unsupported types. + **customHeaders:** Allows you to add custom HTTP headers to all API requests. This is useful for request tracing, monitoring, API gateway routing, or when different models require different headers. If `customHeaders` is defined in `modelProviders[].generationConfig.customHeaders`, it will be used directly; otherwise, headers from `model.generationConfig.customHeaders` will be used. No merging occurs between the two levels. diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 9a3b2c051..ba980db80 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -121,7 +121,9 @@ Environment Variables: Commands executed via `!` will set the `QWEN_CODE=1` envi Save frequently used prompts as shortcut commands to improve work efficiency and ensure consistency. -> **Note:** Custom commands now use Markdown format with optional YAML frontmatter. TOML format is deprecated but still supported for backwards compatibility. When TOML files are detected, an automatic migration prompt will be displayed. +> [!note] +> +> Custom commands now use Markdown format with optional YAML frontmatter. TOML format is deprecated but still supported for backwards compatibility. When TOML files are detected, an automatic migration prompt will be displayed. ### Quick Overview @@ -137,10 +139,10 @@ Priority Rules: Project commands > User commands (project command used when name #### File Path to Command Name Mapping Table -| File Location | Generated Command | Example Call | -| -------------------------- | ----------------- | --------------------- | -| `~/.qwen/commands/test.md` | `/test` | `/test Parameter` | -| `/git/commit.md` | `/git:commit` | `/git:commit Message` | +| File Location | Generated Command | Example Call | +| ---------------------------------------- | ----------------- | --------------------- | +| `~/.qwen/commands/test.md` | `/test` | `/test Parameter` | +| `/.qwen/commands/git/commit.md` | `/git:commit` | `/git:commit Message` | Naming Rules: Path separator (`/` or `\`) converted to colon (`:`) @@ -164,6 +166,8 @@ Use {{args}} for parameter injection. ### TOML File Format (Deprecated) +> [!warning] +> > **Deprecated:** TOML format is still supported but will be removed in a future version. Please migrate to Markdown format. | Field | Required | Description | Example | @@ -225,8 +229,6 @@ Please generate a Commit message based on the following diff: ``` ```` -```` - #### 4. File Content Injection (`@{...}`) | File Type | Support Status | Processing Method | @@ -246,7 +248,7 @@ description: Code review based on best practices Review {{args}}, reference standards: @{docs/code-standards.md} -```` +``` ### Practical Creation Example diff --git a/docs/users/overview.md b/docs/users/overview.md index 3b45cc2f0..f3c52be91 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -7,25 +7,24 @@ ## 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) +**Linux / macOS** -```bash -npm install -g @qwen-code/qwen-code@latest +```sh +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash ``` -**Homebrew**(macOS, Linux) +**Windows (Run as Administrator CMD)** -```bash -brew install qwen-code +```sh +curl -fsSL -o %TEMP%\install-qwen.bat https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat && %TEMP%\install-qwen.bat ``` +> [!note] +> +> It's recommended to restart your terminal after installation to ensure environment variables take effect. If the installation fails, please refer to [Manual Installation](./quickstart#manual-installation) in the Quickstart guide. + ### Start using Qwen Code: ```bash diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index eac8f9474..3c4eafcea 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -16,19 +16,39 @@ Make sure you have: To install Qwen Code, use one of the following methods: -### NPM (recommended) +### Quick Install (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/): +**Linux / macOS** ```sh +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash +``` + +**Windows (Run as Administrator CMD)** + +```sh +curl -fsSL -o %TEMP%\install-qwen.bat https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat && %TEMP%\install-qwen.bat +``` + +> [!note] +> +> It's recommended to restart your terminal after installation to ensure environment variables take effect. + +### Manual Installation + +**Prerequisites** + +Make sure you have Node.js 20 or later installed. Download it from [nodejs.org](https://nodejs.org/en/download). + +**NPM** + +```bash npm install -g @qwen-code/qwen-code@latest ``` -### Homebrew (macOS, Linux) +**Homebrew (macOS, Linux)** -```sh +```bash brew install qwen-code ``` diff --git a/docs/users/reference/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md index f0cbd7b16..fdfc41b87 100644 --- a/docs/users/reference/keyboard-shortcuts.md +++ b/docs/users/reference/keyboard-shortcuts.md @@ -40,6 +40,7 @@ This document lists the available keyboard shortcuts in Qwen Code. | `Ctrl+N` | Navigate down through the input history. | | `Ctrl+P` | Navigate up through the input history. | | `Ctrl+R` | Reverse search through input/shell history. | +| `Ctrl+Y` | Retry the last failed request. | | `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. | | `Ctrl+U` | Delete from the cursor to the beginning of the line. | | `Ctrl+V` (Windows: `Alt+V`) | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. | diff --git a/docs/users/support/tos-privacy.md b/docs/users/support/tos-privacy.md index aa0d5c471..386153512 100644 --- a/docs/users/support/tos-privacy.md +++ b/docs/users/support/tos-privacy.md @@ -4,17 +4,19 @@ Qwen Code is an open-source AI coding assistant tool maintained by the Qwen Code ## How to determine your authentication method -Qwen Code supports two main authentication methods to access AI models. Your authentication method determines which terms of service and privacy policies apply to your usage: +Qwen Code supports three authentication methods to access AI models. Your authentication method determines which terms of service and privacy policies apply to your usage: -1. **Qwen OAuth** - Log in with your qwen.ai account -2. **OpenAI-Compatible API** - Use API keys from various AI model providers +1. **Qwen OAuth** — Log in with your qwen.ai account (free daily quota) +2. **Alibaba Cloud Coding Plan** — Use an API key from Alibaba Cloud +3. **API Key** — Bring your own API key For each authentication method, different Terms of Service and Privacy Notices may apply depending on the underlying service provider. -| Authentication Method | Provider | Terms of Service | Privacy Notice | -| :-------------------- | :---------------- | :---------------------------------------------------------------------------- | :--------------------------------------------------- | -| Qwen OAuth | Qwen AI | [Qwen Terms of Service](https://qwen.ai/termsservice) | [Qwen Privacy Policy](https://qwen.ai/privacypolicy) | -| OpenAI-Compatible API | Various Providers | Depends on your chosen API provider (OpenAI, Alibaba Cloud, ModelScope, etc.) | Depends on your chosen API provider | +| Authentication Method | Provider | Terms of Service | Privacy Notice | +| :------------------------ | :---------------- | :----------------------------------------------------------------- | :----------------------------------------------------------------- | +| Qwen OAuth | Qwen AI | [Qwen Terms of Service](https://qwen.ai/termsservice) | [Qwen Privacy Policy](https://qwen.ai/privacypolicy) | +| Alibaba Cloud Coding Plan | Alibaba Cloud | See [details below](#2-if-you-are-using-alibaba-cloud-coding-plan) | See [details below](#2-if-you-are-using-alibaba-cloud-coding-plan) | +| API Key | Various Providers | Depends on your chosen API provider (OpenAI, Anthropic, etc.) | Depends on your chosen API provider | ## 1. If you are using Qwen OAuth Authentication @@ -25,13 +27,26 @@ When you authenticate using your qwen.ai account, these Terms of Service and Pri For details about authentication setup, quotas, and supported features, see [Authentication Setup](../configuration/settings). -## 2. If you are using OpenAI-Compatible API Authentication +## 2. If you are using Alibaba Cloud Coding Plan -When you authenticate using API keys from OpenAI-compatible providers, the applicable Terms of Service and Privacy Notice depend on your chosen provider. +When you authenticate using an API key from Alibaba Cloud, the applicable Terms of Service and Privacy Notice from Alibaba Cloud apply. + +Alibaba Cloud Coding Plan is available in two regions: + +- **阿里云百炼 (aliyun.com)** — [bailian.console.aliyun.com](https://bailian.console.aliyun.com) +- **Alibaba Cloud (alibabacloud.com)** — [bailian.console.alibabacloud.com](https://bailian.console.alibabacloud.com) > [!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. +> When using Alibaba Cloud Coding Plan, you are subject to Alibaba Cloud's terms and privacy policies. Please review their documentation for specific details about data usage, retention, and privacy practices. + +## 3. If you are using your own API Key + +When you authenticate using API keys from other providers, the applicable Terms of Service and Privacy Notice depend on your chosen provider. + +> [!important] +> +> When using your own API key, 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. @@ -50,7 +65,8 @@ When enabled, Qwen Code may collect: ### Data Collection by Authentication Method - **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. +- **Alibaba Cloud Coding Plan:** Usage statistics are governed by Alibaba Cloud's privacy policy. You can opt-out through Qwen Code's configuration settings. +- **API Key:** No additional data is collected by Qwen Code beyond what your chosen API provider collects. ## Frequently Asked Questions (FAQ) @@ -60,7 +76,9 @@ Whether your code, including prompts and answers, is used to train AI models dep - **Qwen OAuth**: Data usage is governed by [Qwen's Privacy Policy](https://qwen.ai/privacy). Please refer to their policy for specific details about data collection and model training practices. -- **OpenAI-Compatible API**: Data usage depends entirely on your chosen API provider. Each provider has their own data usage policies. Please review the privacy policy and terms of service of your specific provider. +- **Alibaba Cloud Coding Plan**: Data usage is governed by Alibaba Cloud's privacy policy. Please refer to their policy for specific details about data collection and model training practices. + +- **API Key**: Data usage depends entirely on your chosen API provider. Each provider has their own data usage policies. Please review the privacy policy and terms of service of your specific provider. **Important**: Qwen Code itself does not use your prompts, code, or responses for model training. Any data usage for training purposes would be governed by the policies of the AI service provider you authenticate with. @@ -85,10 +103,10 @@ The Usage Statistics setting only controls data collection by Qwen Code itself. ### 3. How do I switch between authentication methods? -You can switch between Qwen OAuth and OpenAI-compatible API authentication at any time: +You can switch between Qwen OAuth, Alibaba Cloud Coding Plan, and your own API key at any time: 1. **During startup**: Choose your preferred authentication method when prompted 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 +3. **Environment variables**: Set up `.env` files for automatic API key authentication For detailed instructions, see the [Authentication Setup](../configuration/settings#environment-variables-for-api-access) documentation. diff --git a/eslint.config.js b/eslint.config.js index 1d0ed2af9..d0963e876 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -254,9 +254,12 @@ export default tseslint.config( 'no-console': 'off', }, }, - // Settings for export-html assets + // Settings for web-templates assets { - files: ['packages/cli/assets/export-html/**/*.{js,jsx,ts,tsx}'], + files: [ + 'packages/web-templates/src/**/*.{js,jsx,ts,tsx}', + 'packages/web-templates/*.mjs', + ], languageOptions: { globals: { ...globals.browser, @@ -271,6 +274,8 @@ export default tseslint.config( rules: { 'react/react-in-jsx-scope': 'off', 'react/prop-types': 'off', + 'no-console': 'off', + 'no-undef': 'off', }, }, // Prettier config must be last diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index 93389d605..a0f7a2629 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -472,6 +472,156 @@ function setupAcpTest( } }); + it('supports session/set_config_option for mode and model', async () => { + const rig = new TestRig(); + rig.setup('acp set config option'); + + const { sendRequest, cleanup, stderr } = setupAcpTest(rig); + + try { + // Initialize + await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + }); + + await sendRequest('authenticate', { methodId: 'openai' }); + + // Create a new session + const newSession = (await sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + })) as { + sessionId: string; + models: { + availableModels: Array<{ modelId: string }>; + }; + }; + expect(newSession.sessionId).toBeTruthy(); + + // Test: Set mode using set_config_option + const setModeResult = (await sendRequest('session/set_config_option', { + sessionId: newSession.sessionId, + configId: 'mode', + value: 'yolo', + })) as { + configOptions: Array<{ + id: string; + currentValue: string; + options: Array<{ value: string; name: string; description: string }>; + }>; + }; + + expect(setModeResult).toBeDefined(); + expect(Array.isArray(setModeResult.configOptions)).toBe(true); + expect(setModeResult.configOptions.length).toBeGreaterThanOrEqual(2); + + // Find mode option + const modeOption = setModeResult.configOptions.find( + (opt) => opt.id === 'mode', + ); + expect(modeOption).toBeDefined(); + expect(modeOption!.currentValue).toBe('yolo'); + expect(Array.isArray(modeOption!.options)).toBe(true); + expect(modeOption!.options.some((o) => o.value === 'yolo')).toBe(true); + + // Find model option + const modelOption = setModeResult.configOptions.find( + (opt) => opt.id === 'model', + ); + expect(modelOption).toBeDefined(); + expect(modelOption!.currentValue).toBeTruthy(); + + // Test: Set model using set_config_option + // Use openai model to avoid auth issues + const openaiModel = newSession.models.availableModels.find((model) => + model.modelId.includes('openai'), + ); + expect(openaiModel).toBeDefined(); + + const setModelResult = (await sendRequest('session/set_config_option', { + sessionId: newSession.sessionId, + configId: 'model', + value: openaiModel!.modelId, + })) as { + configOptions: Array<{ + id: string; + currentValue: string; + options: Array<{ value: string; name: string; description: string }>; + }>; + }; + + expect(setModelResult).toBeDefined(); + expect(Array.isArray(setModelResult.configOptions)).toBe(true); + + // Verify model was updated + const updatedModelOption = setModelResult.configOptions.find( + (opt) => opt.id === 'model', + ); + expect(updatedModelOption).toBeDefined(); + expect(updatedModelOption!.currentValue).toBe(openaiModel!.modelId); + } catch (e) { + if (stderr.length) { + console.error('Agent stderr:', stderr.join('')); + } + throw e; + } finally { + await cleanup(); + } + }); + + it('returns error for invalid configId in set_config_option', async () => { + const rig = new TestRig(); + rig.setup('acp set config option error'); + + const { sendRequest, cleanup, stderr } = setupAcpTest(rig); + + try { + // Initialize + await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + }); + + await sendRequest('authenticate', { methodId: 'openai' }); + + // Create a new session + const newSession = (await sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + })) as { sessionId: string }; + expect(newSession.sessionId).toBeTruthy(); + + // Test: Invalid configId should return error + await expect( + sendRequest('session/set_config_option', { + sessionId: newSession.sessionId, + configId: 'invalid_config', + value: 'some_value', + }), + ).rejects.toMatchObject({ + response: { + code: -32602, + message: 'Invalid params', + data: { + details: 'Unsupported configId: invalid_config', + }, + }, + }); + } catch (e) { + if (stderr.length) { + console.error('Agent stderr:', stderr.join('')); + } + throw e; + } finally { + await cleanup(); + } + }); + it('receives available_commands_update with slash commands after session creation', async () => { const rig = new TestRig(); rig.setup('acp slash commands'); @@ -659,7 +809,13 @@ function setupAcpTest( }> = []; const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig, { - permissionHandler: () => ({ optionId: 'proceed_once' }), + permissionHandler: (request) => { + // Cancel exit_plan_mode to keep plan mode active + if (request.toolCall?.kind === 'switch_mode') { + return { outcome: 'cancelled' }; + } + return { optionId: 'proceed_once' }; + }, }); try { diff --git a/integration-tests/fixtures/settings-migration/workspaces.json b/integration-tests/fixtures/settings-migration/workspaces.json new file mode 100644 index 000000000..af7a48f84 --- /dev/null +++ b/integration-tests/fixtures/settings-migration/workspaces.json @@ -0,0 +1,189 @@ +{ + "v1Settings": { + "theme": "dark", + "model": "gemini", + "autoAccept": true, + "hideTips": false, + "vimMode": true, + "checkpointing": true, + "disableAutoUpdate": true, + "disableLoadingPhrases": true, + "mcpServers": { + "fetch": { + "command": "node", + "args": ["fetch-server.js"] + } + }, + "customUserSetting": "preserved-value" + }, + "v1ComplexSettings": { + "theme": "dark", + "model": "gemini-1.5-pro", + "autoAccept": false, + "hideTips": true, + "vimMode": false, + "checkpointing": true, + "disableAutoUpdate": true, + "disableUpdateNag": false, + "disableLoadingPhrases": true, + "disableFuzzySearch": false, + "disableCacheControl": true, + "allowedTools": ["read-file", "write-file"], + "allowMCPServers": true, + "autoConfigureMaxOldSpaceSize": true, + "bugCommand": "/bug", + "chatCompression": "auto", + "coreTools": ["edit", "bash"], + "customThemes": [], + "customWittyPhrases": [], + "fileFiltering": true, + "folderTrust": true, + "ideMode": true, + "includeDirectories": ["src", "lib"], + "maxSessionTurns": 50, + "preferredEditor": "vscode", + "sandbox": false, + "summarizeToolOutput": true, + "telemetry": { + "enabled": false + }, + "useRipgrep": true, + "myCustomKey": "custom-value", + "anotherCustomSetting": { + "nested": true, + "items": [1, 2, 3] + } + }, + "v1ArrayAndNullSettings": { + "theme": null, + "model": ["gemini", "claude"], + "autoAccept": false, + "includeDirectories": [], + "disableFuzzySearch": "TRUE", + "disableCacheControl": "FALSE", + "customArray": [{ "key": 1 }] + }, + "v1ParentCollisionSettings": { + "theme": "dark", + "model": "gemini", + "ui": "legacy-ui-string", + "general": "legacy-general-string", + "disableAutoUpdate": true, + "disableLoadingPhrases": false, + "notes": { + "fromUser": "preserve-custom" + } + }, + "v1VersionStringSettings": { + "$version": "2", + "theme": "light", + "model": "qwen-plus", + "disableAutoUpdate": "false", + "disableLoadingPhrases": "TRUE", + "ui": { + "hideWindowTitle": true + }, + "customSection": { + "keepMe": true + } + }, + "v2Settings": { + "$version": 2, + "ui": { + "theme": "light", + "accessibility": { + "disableLoadingPhrases": false + } + }, + "general": { + "disableAutoUpdate": false, + "disableUpdateNag": false, + "checkpointing": false + }, + "model": { + "name": "claude" + }, + "context": { + "fileFiltering": { + "disableFuzzySearch": true + } + }, + "mcpServers": {} + }, + "v2MinimalSettings": { + "$version": 2 + }, + "v2BooleanStringSettings": { + "$version": 2, + "general": { + "disableAutoUpdate": "TRUE", + "disableUpdateNag": "false" + }, + "ui": { + "accessibility": { + "disableLoadingPhrases": "FaLsE" + } + }, + "context": { + "fileFiltering": { + "disableFuzzySearch": "TRUE" + } + }, + "model": { + "generationConfig": { + "disableCacheControl": "false" + } + } + }, + "v2PreexistingEnableSettings": { + "$version": 2, + "general": { + "disableAutoUpdate": false, + "disableUpdateNag": true, + "enableAutoUpdate": true + }, + "ui": { + "accessibility": { + "disableLoadingPhrases": true, + "enableLoadingPhrases": true + } + }, + "context": { + "fileFiltering": { + "disableFuzzySearch": false, + "enableFuzzySearch": false + } + }, + "model": { + "generationConfig": { + "disableCacheControl": true, + "enableCacheControl": true + } + } + }, + "v3LegacyDisableSettings": { + "$version": 3, + "general": { + "disableAutoUpdate": true, + "enableAutoUpdate": false + }, + "ui": { + "accessibility": { + "disableLoadingPhrases": false, + "enableLoadingPhrases": true + } + }, + "custom": { + "note": "should remain unchanged in v3" + } + }, + "v999FutureVersionSettings": { + "$version": 999, + "theme": "dark", + "model": "future-model", + "disableAutoUpdate": true, + "experimentalFlag": { + "enabled": true + } + } +} diff --git a/integration-tests/globalSetup.ts b/integration-tests/globalSetup.ts index a8a9877fe..02cea6859 100644 --- a/integration-tests/globalSetup.ts +++ b/integration-tests/globalSetup.ts @@ -94,7 +94,7 @@ export async function setup() { // Environment variables for CLI integration tests process.env['INTEGRATION_TEST_FILE_DIR'] = runDir; - process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true'; + process.env['QWEN_CODE_INTEGRATION_TEST'] = 'true'; process.env['TELEMETRY_LOG_FILE'] = join(runDir, 'telemetry.log'); // Environment variables for SDK E2E tests diff --git a/integration-tests/hook-integration/hooks.test.ts b/integration-tests/hook-integration/hooks.test.ts new file mode 100644 index 000000000..f134dc1ab --- /dev/null +++ b/integration-tests/hook-integration/hooks.test.ts @@ -0,0 +1,1946 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig, validateModelOutput } from '../test-helper.js'; + +/** + * Hooks System Integration Tests + * + * Tests for complete hook system flow including: + * - UserPromptSubmit hooks: Triggered before prompt is sent to LLM + * - Stop hooks: Triggered when agent is about to stop + * + * Test categories: + * - Single hook scenarios (allow, block, modify, context, etc.) + * - Multiple hooks scenarios (parallel, sequential, mixed) + * - Error handling (timeout, missing command, exit codes) + * - Combined hooks (multiple hook types in same session) + */ +describe('Hooks System Integration', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + if (rig) { + await rig.cleanup(); + } + }); + + // ========================================================================== + // UserPromptSubmit Hooks + // Triggered before user prompt is sent to the LLM for processing + // ========================================================================== + describe('UserPromptSubmit Hooks', () => { + describe('Allow Decision', () => { + it('should allow prompt when hook returns allow decision', async () => { + const hookScript = + "console.log(JSON.stringify({decision: 'allow', reason: 'approved by hook'}));"; + + await rig.setup('ups-allow-decision', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${hookScript}"`, + name: 'ups-allow-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should allow tool execution with allow decision and verify tool was called', async () => { + const hookScript = + "console.log(JSON.stringify({decision: 'allow', reason: 'Tool execution approved'}));"; + + await rig.setup('ups-allow-tool', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${hookScript}"`, + name: 'ups-allow-tool-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + await rig.run('Create a file test.txt with content "hello"'); + + const foundToolCall = await rig.waitForToolCall('write_file'); + expect(foundToolCall).toBeTruthy(); + + const fileContent = rig.readFile('test.txt'); + expect(fileContent).toContain('hello'); + }); + }); + + describe('Block Decision', () => { + it('should block prompt when hook returns block decision', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Prompt blocked by security policy'}));`; + + await rig.setup('ups-block-decision', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + + // Blocked prompts should show the block reason + expect(result.toLowerCase()).toContain('block'); + }); + + it('should block tool execution when hook returns block and verify no tool was called', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'File writing blocked by security policy'}));`; + + await rig.setup('ups-block-tool', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-block-tool-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file test.txt with "hello"'); + + // Tool should not be called due to blocking hook + const toolLogs = rig.readToolLogs(); + const writeFileCalls = toolLogs.filter( + (t) => + t.toolRequest.name === 'write_file' && + t.toolRequest.success === true, + ); + expect(writeFileCalls).toHaveLength(0); + + // Result should mention the blocking reason + expect(result).toContain('block'); + }); + }); + + describe('Modify Prompt', () => { + it('should use modified prompt when hook provides modification', async () => { + const modifyScript = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'UserPromptSubmit', modifiedPrompt: 'Modified prompt content', additionalContext: 'Context added by hook'}}));`; + + await rig.setup('ups-modify-prompt', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${modifyScript}"`, + name: 'ups-modify-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say test'); + expect(result).toBeDefined(); + }); + }); + + describe('Additional Context', () => { + it('should include additional context in response when hook provides it', async () => { + const contextScript = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'Extra context information from hook'}}));`; + + await rig.setup('ups-add-context', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${contextScript}"`, + name: 'ups-context-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('What is 1+1?'); + expect(result).toBeDefined(); + }); + }); + + describe('Timeout Handling', () => { + it('should continue execution when hook times out', async () => { + await rig.setup('ups-timeout', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: 'sleep 60', + name: 'ups-timeout-hook', + timeout: 1000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say timeout test'); + // Should continue despite timeout + expect(result).toBeDefined(); + }); + }); + + describe('Error Handling', () => { + it('should continue execution when hook exits with non-blocking error (exit code 1)', async () => { + await rig.setup('ups-nonblocking-error', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: 'echo warning && exit 1', + name: 'ups-error-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say error test'); + // Non-blocking error should not prevent execution + expect(result).toBeDefined(); + }); + + it('should block execution when hook exits with blocking error (exit code 2)', async () => { + await rig.setup('ups-blocking-error', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: + 'node -e "console.error(\'Critical security error\'); process.exit(2)"', + name: 'ups-blocking-error-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + expect(result).toBeDefined(); + }); + + it('should continue execution when hook command does not exist', async () => { + await rig.setup('ups-missing-command', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: '/nonexistent/command/path', + name: 'ups-missing-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say missing test'); + // Missing command should not prevent execution (non-blocking) + expect(result).toBeDefined(); + }); + }); + + describe('Input Format Validation', () => { + it('should receive properly formatted input when hook is called', async () => { + const inputValidationScript = ` +const input = JSON.parse(process.argv[2] || '{}'); +const hasRequired = input.session_id && input.cwd && input.hook_event_name && input.prompt !== undefined; +console.log(JSON.stringify({ + decision: 'allow', + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: hasRequired ? 'Valid input format' : 'Invalid input format' + } +})); +`; + + await rig.setup('ups-correct-input', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${inputValidationScript.replace(/\n/g, ' ')}"`, + name: 'ups-input-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say input test'); + validateModelOutput(result, 'input test', 'UPS: correct input'); + }); + }); + + describe('System Message', () => { + it('should include system message in response when hook provides it', async () => { + const systemMsgScript = `console.log(JSON.stringify({decision: 'allow', systemMessage: 'This is a system message from hook'}));`; + + await rig.setup('ups-system-message', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${systemMsgScript}"`, + name: 'ups-system-msg-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say system message'); + expect(result).toBeDefined(); + }); + }); + + describe('Multiple UserPromptSubmit Hooks', () => { + it('should block when one of multiple parallel hooks returns block', async () => { + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'Allowed'}));`; + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked by security policy'}));`; + + await rig.setup('ups-multi-one-blocks', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'ups-allow-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // When any hook blocks, the result should reflect the block + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should block when first sequential hook returns block', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'First hook blocks'}));`; + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'This should not run'}));`; + + await rig.setup('ups-seq-first-blocks', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-seq-block-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'ups-seq-allow-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // First hook blocks, second should not run + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should block when second sequential hook returns block', async () => { + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'First allows'}));`; + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Second hook blocks'}));`; + + await rig.setup('ups-seq-second-blocks', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'ups-seq-first-allow', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-seq-second-block', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // Second hook blocks after first allows + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle multiple hooks all returning allow', async () => { + const allow1Script = `console.log(JSON.stringify({decision: 'allow', reason: 'First allows'}));`; + const allow2Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Second allows'}));`; + const allow3Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Third allows'}));`; + + await rig.setup('ups-multi-all-allow', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allow1Script}"`, + name: 'ups-allow-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allow2Script}"`, + name: 'ups-allow-2', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allow3Script}"`, + name: 'ups-allow-3', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + // All hooks allow, should complete normally + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle multiple hooks all returning block', async () => { + const block1Script = `console.log(JSON.stringify({decision: 'block', reason: 'First blocks'}));`; + const block2Script = `console.log(JSON.stringify({decision: 'block', reason: 'Second blocks'}));`; + + await rig.setup('ups-multi-all-block', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${block1Script}"`, + name: 'ups-block-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${block2Script}"`, + name: 'ups-block-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // All hooks block + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should concatenate additional context from multiple hooks', async () => { + const context1Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'context from hook 1'}}));`; + const context2Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'context from hook 2'}}));`; + + await rig.setup('ups-multi-context', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${context1Script}"`, + name: 'ups-context-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${context2Script}"`, + name: 'ups-context-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + }); + + it('should handle hook with error alongside blocking hook', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked'}));`; + + await rig.setup('ups-error-with-block', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: '/nonexistent/command', + name: 'ups-error-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // Block should still work despite error in other hook + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle hook timeout alongside blocking hook', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked while other times out'}));`; + + await rig.setup('ups-timeout-with-block', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: 'sleep 60', + name: 'ups-timeout-hook', + timeout: 1000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // Block should work despite timeout in other hook + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle multiple hook groups with different configurations', async () => { + const allow1Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Group 1 allows'}));`; + const allow2Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Group 2 allows'}));`; + + await rig.setup('ups-multi-groups', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allow1Script}"`, + name: 'ups-group1-hook', + timeout: 5000, + }, + ], + }, + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${allow2Script}"`, + name: 'ups-group2-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + }); + + it('should block when one group blocks in multiple hook groups', async () => { + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'Group 1 allows'}));`; + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Group 2 blocks'}));`; + + await rig.setup('ups-multi-groups-one-blocks', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'ups-group1-allow', + timeout: 5000, + }, + ], + }, + { + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'ups-group2-block', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // One group blocks, should be blocked + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle modified prompt from multiple hooks', async () => { + const modify1Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {modifiedPrompt: 'Modified by hook 1'}}));`; + const modify2Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {modifiedPrompt: 'Modified by hook 2'}}));`; + + await rig.setup('ups-multi-modify', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${modify1Script}"`, + name: 'ups-modify-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${modify2Script}"`, + name: 'ups-modify-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + }); + + it('should handle system messages from multiple hooks', async () => { + const msg1Script = `console.log(JSON.stringify({decision: 'allow', systemMessage: 'System message 1'}));`; + const msg2Script = `console.log(JSON.stringify({decision: 'allow', systemMessage: 'System message 2'}));`; + + await rig.setup('ups-multi-system-msg', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${msg1Script}"`, + name: 'ups-msg-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${msg2Script}"`, + name: 'ups-msg-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + expect(result).toBeDefined(); + }); + }); + }); + + // ========================================================================== + // Stop Hooks + // Triggered when the agent is about to stop execution + // ========================================================================== + describe('Stop Hooks', () => { + describe('Allow Decision', () => { + it('should allow stopping when hook returns allow decision', async () => { + const allowStopScript = `console.log(JSON.stringify({decision: 'allow', reason: 'Stop allowed'}));`; + + await rig.setup('stop-allow', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allowStopScript}"`, + name: 'stop-allow-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say stop test'); + expect(result).toBeDefined(); + }); + + it('should allow stopping and verify final response is produced', async () => { + const allowFinalScript = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'Final context from stop hook'}}));`; + + await rig.setup('stop-allow-final', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allowFinalScript}"`, + name: 'stop-final-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say goodbye'); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('Block Decision', () => { + it('should block stopping when hook returns block decision', async () => { + const blockStopScript = `console.log(JSON.stringify({decision: 'block', reason: 'Stop blocked by security policy'}));`; + + await rig.setup('stop-block-decision', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${blockStopScript}"`, + name: 'stop-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say hello'); + // Blocked stop should show the block reason + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should block stopping with custom reason', async () => { + const blockReasonScript = `console.log(JSON.stringify({decision: 'block', reason: 'Custom block reason: task incomplete'}));`; + + await rig.setup('stop-block-custom-reason', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${blockReasonScript}"`, + name: 'stop-block-reason-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say goodbye'); + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + }); + + describe('Continue False', () => { + it('should request continue execution when hook returns continue: false', async () => { + const continueScript = `console.log(JSON.stringify({continue: false, stopReason: 'More work needed'}));`; + + await rig.setup('stop-continue-false', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${continueScript}"`, + name: 'stop-continue-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say continue'); + // When continue: false, the agent may try to continue + expect(result).toBeDefined(); + }); + }); + + describe('Additional Context', () => { + it('should include additional context in final response', async () => { + const contextScript = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'Final context from hook'}}));`; + + await rig.setup('stop-add-context', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${contextScript}"`, + name: 'stop-context-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('What is 3+3?'); + expect(result).toBeDefined(); + }); + + it('should concatenate multiple additionalContext from multiple hooks', async () => { + const context1Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'context1'}}));`; + const context2Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'context2'}}));`; + + await rig.setup('stop-multi-context', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${context1Script}"`, + name: 'stop-context-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${context2Script}"`, + name: 'stop-context-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say multi context'); + expect(result).toBeDefined(); + }); + }); + + describe('Stop Reason', () => { + it('should include stop reason when hook provides it', async () => { + const reasonScript = `console.log(JSON.stringify({decision: 'allow', stopReason: 'Custom stop reason from hook'}));`; + + await rig.setup('stop-set-reason', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${reasonScript}"`, + name: 'stop-reason-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say reason test'); + expect(result).toBeDefined(); + }); + }); + + describe('Timeout Handling', () => { + it('should continue stopping when hook times out', async () => { + await rig.setup('stop-timeout', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: 'sleep 60', + name: 'stop-timeout-hook', + timeout: 1000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say timeout'); + // Timeout should not prevent stopping + expect(result).toBeDefined(); + }); + }); + + describe('Error Handling', () => { + it('should continue stopping when hook has non-blocking error', async () => { + await rig.setup('stop-error', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: 'echo warning && exit 1', + name: 'stop-error-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say error'); + // Error should not prevent stopping + expect(result).toBeDefined(); + }); + + it('should continue stopping when hook command does not exist', async () => { + await rig.setup('stop-missing-command', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: '/nonexistent/stop/command', + name: 'stop-missing-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say missing'); + // Missing command should not prevent stopping + expect(result).toBeDefined(); + }); + }); + + describe('System Message', () => { + it('should include system message in final response', async () => { + const systemMsgScript = `console.log(JSON.stringify({decision: 'allow', systemMessage: 'Final system message from stop hook'}));`; + + await rig.setup('stop-system-message', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${systemMsgScript}"`, + name: 'stop-system-msg-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say final'); + expect(result).toBeDefined(); + }); + }); + + describe('Multiple Stop Hooks', () => { + it('should block when one of multiple parallel stop hooks returns block', async () => { + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'Stop allowed'}));`; + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Stop blocked by security policy'}));`; + + await rig.setup('stop-multi-one-blocks', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'stop-allow-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'stop-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say multi stop'); + // When any hook blocks, the result should reflect the block + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should block when first sequential stop hook returns block', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'First hook blocks stop'}));`; + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'This should not run'}));`; + + await rig.setup('stop-seq-first-blocks', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'stop-seq-block-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'stop-seq-allow-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say sequential stop'); + // First hook blocks, second should not run + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should block when second sequential stop hook returns block', async () => { + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'First allows'}));`; + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Second hook blocks stop'}));`; + + await rig.setup('stop-seq-second-blocks', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'stop-seq-first-allow', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'stop-seq-second-block', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say seq second blocks'); + // Second hook blocks after first allows + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle multiple stop hooks all returning allow', async () => { + const allow1Script = `console.log(JSON.stringify({decision: 'allow', reason: 'First allows'}));`; + const allow2Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Second allows'}));`; + const allow3Script = `console.log(JSON.stringify({decision: 'allow', reason: 'Third allows'}));`; + + await rig.setup('stop-multi-all-allow', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allow1Script}"`, + name: 'stop-allow-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allow2Script}"`, + name: 'stop-allow-2', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allow3Script}"`, + name: 'stop-allow-3', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say all allow'); + // All hooks allow, should complete normally + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle multiple stop hooks all returning block', async () => { + const block1Script = `console.log(JSON.stringify({decision: 'block', reason: 'First blocks'}));`; + const block2Script = `console.log(JSON.stringify({decision: 'block', reason: 'Second blocks'}));`; + + await rig.setup('stop-multi-all-block', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${block1Script}"`, + name: 'stop-block-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${block2Script}"`, + name: 'stop-block-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say all block'); + // All hooks block + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle multiple continue: false from different stop hooks', async () => { + const continue1Script = `console.log(JSON.stringify({continue: false, stopReason: 'First needs more work'}));`; + const continue2Script = `console.log(JSON.stringify({continue: false, stopReason: 'Second needs more work'}));`; + + await rig.setup('stop-multi-continue-false', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${continue1Script}"`, + name: 'stop-continue-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${continue2Script}"`, + name: 'stop-continue-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say multi continue'); + // Multiple continue: false should be handled + expect(result).toBeDefined(); + }); + + it('should handle mixed allow and continue: false in stop hooks', async () => { + const allowScript = `console.log(JSON.stringify({decision: 'allow', reason: 'Allow stop'}));`; + const continueScript = `console.log(JSON.stringify({continue: false, stopReason: 'Need more work'}));`; + + await rig.setup('stop-mixed-allow-continue', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'stop-allow-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${continueScript}"`, + name: 'stop-continue-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say mixed'); + expect(result).toBeDefined(); + }); + + it('should handle block with higher priority than continue: false', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Security block'}));`; + const continueScript = `console.log(JSON.stringify({continue: false, stopReason: 'Need more work'}));`; + + await rig.setup('stop-block-vs-continue', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'stop-block-priority', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${continueScript}"`, + name: 'stop-continue-lower', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say block priority'); + // Block should take priority + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle stop hook with error alongside blocking hook', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked'}));`; + + await rig.setup('stop-error-with-block', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: '/nonexistent/command', + name: 'stop-error-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'stop-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say error with block'); + // Block should still work despite error in other hook + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + + it('should handle stop hook timeout alongside blocking hook', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked while other times out'}));`; + + await rig.setup('stop-timeout-with-block', { + settings: { + hooks: { + enabled: true, + Stop: [ + { + hooks: [ + { + type: 'command', + command: 'sleep 60', + name: 'stop-timeout-hook', + timeout: 1000, + }, + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'stop-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say timeout with block'); + // Block should work despite timeout in other hook + expect(result).toBeDefined(); + expect(result.toLowerCase()).toContain('block'); + }); + }); + }); + + // ========================================================================== + // Multiple Hooks (General) + // Tests for hook execution modes: sequential vs parallel + // ========================================================================== + describe('Multiple Hooks', () => { + describe('Sequential Execution', () => { + it('should execute hooks sequentially when sequential: true', async () => { + const hook1Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'first'}}));`; + const hook2Script = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'second'}}));`; + + await rig.setup('multi-sequential', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${hook1Script}"`, + name: 'seq-hook-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${hook2Script}"`, + name: 'seq-hook-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say sequential'); + expect(result).toBeDefined(); + }); + + it('should stop at first blocking hook and not execute subsequent', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'Blocked by first hook'}));`; + const allowScript = `console.log(JSON.stringify({decision: 'allow'}));`; + + await rig.setup('multi-first-blocks', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'seq-block-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'seq-should-not-run', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + // First hook blocks, second should not run + expect(result.toLowerCase()).toContain('block'); + }); + + it('should pass output from first hook to second hook input', async () => { + const passScript1 = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'from first', passthrough: 'data'}}));`; + const passScript2 = `console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {additionalContext: 'received passthrough'}}));`; + + await rig.setup('multi-passthrough', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + sequential: true, + hooks: [ + { + type: 'command', + command: `node -e "${passScript1}"`, + name: 'passthrough-hook-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${passScript2}"`, + name: 'passthrough-hook-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say passthrough'); + expect(result).toBeDefined(); + }); + }); + + describe('Parallel Execution', () => { + it('should execute hooks in parallel when sequential is not set', async () => { + const hook1Script = `console.log(JSON.stringify({decision: 'allow'}));`; + const hook2Script = `console.log(JSON.stringify({decision: 'allow'}));`; + + await rig.setup('multi-parallel', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${hook1Script}"`, + name: 'parallel-hook-1', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${hook2Script}"`, + name: 'parallel-hook-2', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say parallel'); + expect(result).toBeDefined(); + }); + + it('should handle mixed success/failure results from parallel hooks', async () => { + const allowScript = `console.log(JSON.stringify({decision: 'allow'}));`; + + await rig.setup('multi-mixed', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'mixed-allow-hook', + timeout: 5000, + }, + { + type: 'command', + command: '/nonexistent/command', + name: 'mixed-error-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say mixed'); + // Mixed results: one succeeds, one fails - should continue + expect(result).toBeDefined(); + }); + + it('should allow when any hook returns allow in parallel (OR logic)', async () => { + const blockScript = `console.log(JSON.stringify({decision: 'block', reason: 'blocked'}));`; + const allowScript = `console.log(JSON.stringify({decision: 'allow'}));`; + + await rig.setup('multi-or-logic', { + settings: { + hooks: { + enabled: true, + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${blockScript}"`, + name: 'block-hook', + timeout: 5000, + }, + { + type: 'command', + command: `node -e "${allowScript}"`, + name: 'allow-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say or logic'); + // With OR logic, allow should win + expect(result).toBeDefined(); + }); + }); + }); + + // ========================================================================== + // Combined Hooks + // Tests for using multiple hook types (UserPromptSubmit + Stop) together + // ========================================================================== + describe('Combined Hooks', () => { + it('should execute both Stop and UserPromptSubmit hooks in same session', async () => { + const stopScript = `console.log(JSON.stringify({decision: 'allow'}));`; + const upsScript = `console.log(JSON.stringify({decision: 'allow'}));`; + + await rig.setup('combined-both-hooks', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${stopScript}"`, + name: 'stop-hook', + timeout: 5000, + }, + ], + }, + ], + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: `node -e "${upsScript}"`, + name: 'ups-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say both hooks'); + expect(result).toBeDefined(); + }); + }); + + // ========================================================================== + // Hook Script File Tests + // Tests for executing hooks from external script files + // ========================================================================== + describe('Hook Script File Tests', () => { + it('should execute hook from script file', async () => { + await rig.setup('script-file-hook', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: + "node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Approved by script file', hookSpecificOutput: {additionalContext: 'Script file executed successfully'}}))\"", + name: 'script-file-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Say script file test'); + expect(result).toBeDefined(); + }); + + it('should execute blocking hook from script file', async () => { + await rig.setup('script-file-block-hook', { + settings: { + hooksConfig: { enabled: true }, + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: + "node -e \"console.log(JSON.stringify({decision: 'block', reason: 'Blocked by security script'}))\"", + name: 'script-block-hook', + timeout: 5000, + }, + ], + }, + ], + }, + trusted: true, + }, + }); + + const result = await rig.run('Create a file'); + + // Prompt should be blocked + expect(result.toLowerCase()).toContain('block'); + }); + }); +}); diff --git a/integration-tests/settings-migration.test.ts b/integration-tests/settings-migration.test.ts new file mode 100644 index 000000000..fa5446c17 --- /dev/null +++ b/integration-tests/settings-migration.test.ts @@ -0,0 +1,627 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import { writeFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +// Import settings fixtures from unified workspace file +import workspacesSettings from './fixtures/settings-migration/workspaces.json' with { type: 'json' }; + +const { + v1Settings, + v1ComplexSettings, + v1ArrayAndNullSettings, + v1ParentCollisionSettings, + v1VersionStringSettings, + v2Settings, + v2MinimalSettings, + v2BooleanStringSettings, + v2PreexistingEnableSettings, + v3LegacyDisableSettings, + v999FutureVersionSettings, +} = workspacesSettings; + +/** + * Integration tests for settings migration chain (V1 -> V2 -> V3) + * + * These tests verify that: + * 1. V1 settings are automatically migrated to V3 on CLI startup + * 2. V2 settings are automatically migrated to V3 on CLI startup + * 3. V3 settings remain unchanged + * 4. Migration is idempotent (running multiple times produces same result) + */ +describe('settings-migration', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + /** + * Helper to write settings file for an existing test rig. + * This overwrites the settings file created by rig.setup(). + */ + const overwriteSettingsFile = ( + testRig: TestRig, + settings: Record, + ) => { + const qwenDir = join( + (testRig as unknown as { testDir: string }).testDir, + '.qwen', + ); + writeFileSync( + join(qwenDir, 'settings.json'), + JSON.stringify(settings, null, 2), + ); + }; + + /** + * Helper to read settings file from the test directory + */ + const readSettingsFile = (testRig: TestRig): Record => { + const qwenDir = join( + (testRig as unknown as { testDir: string }).testDir, + '.qwen', + ); + const content = readFileSync(join(qwenDir, 'settings.json'), 'utf-8'); + return JSON.parse(content) as Record; + }; + + describe('V1 settings migration', () => { + it('should migrate V1 settings to V3 on CLI startup', async () => { + rig.setup('v1-to-v3-migration'); + + // Write V1 settings directly (overwrites the one created by setup) + overwriteSettingsFile(rig, v1Settings); + + // Run CLI with --help to trigger migration without API calls + // We expect this to fail due to missing API key, but migration should still occur + try { + await rig.runCommand(['--help']); + } catch { + // Expected to potentially fail, we just need the settings file to be processed + } + + // Read migrated settings + const migratedSettings = readSettingsFile(rig); + + // Verify migration to V3 + expect(migratedSettings['$version']).toBe(3); + expect(migratedSettings['ui']).toEqual({ + theme: 'dark', + hideTips: false, + accessibility: { + enableLoadingPhrases: false, + }, + }); + expect(migratedSettings['model']).toEqual({ name: 'gemini' }); + expect(migratedSettings['tools']).toEqual({ autoAccept: true }); + expect(migratedSettings['general']).toEqual({ + vimMode: true, + checkpointing: true, + enableAutoUpdate: false, + }); + expect(migratedSettings['mcpServers']).toEqual({ + fetch: { + command: 'node', + args: ['fetch-server.js'], + }, + }); + // Custom user settings should be preserved + expect(migratedSettings['customUserSetting']).toBe('preserved-value'); + }); + + it('should handle V1 settings with arrays and null values', async () => { + rig.setup('v1-array-and-null-migration'); + + // Use fixture with arrays, null values, and string booleans + overwriteSettingsFile(rig, v1ArrayAndNullSettings); + + // Run CLI with --help to trigger migration without API calls + try { + await rig.runCommand(['--help']); + } catch { + // Expected to potentially fail + } + + // Read migrated settings + const migratedSettings = readSettingsFile(rig); + + // Expected output based on stable test output + expect(migratedSettings['$version']).toBe(3); + expect(migratedSettings['tools']).toEqual({ autoAccept: false }); + expect(migratedSettings['context']).toEqual({ includeDirectories: [] }); + expect(migratedSettings['model']).toEqual({ name: ['gemini', 'claude'] }); + expect(migratedSettings['ui']).toEqual({ theme: null }); + expect(migratedSettings['customArray']).toEqual([{ key: 1 }]); + }); + + it('should handle V1 settings with parent key collision', async () => { + rig.setup('v1-parent-collision-migration'); + + // Use fixture where V1 flat keys (ui, general) conflict with V2/V3 nested structure + overwriteSettingsFile(rig, v1ParentCollisionSettings); + + // Run CLI with --help to trigger migration without API calls + try { + await rig.runCommand(['--help']); + } catch { + // Expected to potentially fail + } + + // Read migrated settings + const migratedSettings = readSettingsFile(rig); + + // Should be migrated to V3 + expect(migratedSettings['$version']).toBe(3); + // Legacy string values for ui/general should be preserved as-is (user data) + expect(migratedSettings['ui']).toBe('legacy-ui-string'); + expect(migratedSettings['general']).toBe('legacy-general-string'); + // Custom nested objects should be preserved + expect(migratedSettings['notes']).toEqual({ + fromUser: 'preserve-custom', + }); + }); + + it('should handle V1 settings with string version and string booleans', async () => { + rig.setup('v1-string-version-migration'); + + // Use fixture with $version as string and string boolean values + overwriteSettingsFile(rig, v1VersionStringSettings); + + // Run CLI with --help to trigger migration without API calls + try { + await rig.runCommand(['--help']); + } catch { + // Expected to potentially fail + } + + // Read migrated settings + const migratedSettings = readSettingsFile(rig); + + // Expected output based on stable test output + expect(migratedSettings['$version']).toBe(3); + expect(migratedSettings['model']).toEqual({ name: 'qwen-plus' }); + expect(migratedSettings['ui']).toEqual({ + hideWindowTitle: true, + theme: 'light', + }); + // String "false" for disableAutoUpdate is treated as truthy (non-empty string) + // So enableAutoUpdate = !truthy = false, but output shows true + // This suggests string "false" is parsed as boolean false + expect( + (migratedSettings['general'] as Record)?.[ + 'enableAutoUpdate' + ], + ).toBe(true); + // Custom sections should be preserved + expect(migratedSettings['customSection']).toEqual({ keepMe: true }); + }); + }); + + describe('V2 settings migration', () => { + it('should migrate V2 settings to V3 on CLI startup', async () => { + rig.setup('v2-to-v3-migration'); + + // Write V2 settings directly (overwrites the one created by setup) + overwriteSettingsFile(rig, v2Settings); + + // Run CLI with --help to trigger migration without API calls + try { + await rig.runCommand(['--help']); + } catch { + // Expected to potentially fail + } + + // Read migrated settings + const migratedSettings = readSettingsFile(rig); + + // Verify migration to V3 + expect(migratedSettings['$version']).toBe(3); + + // Verify disable* -> enable* conversion with inversion + expect( + ( + (migratedSettings['ui'] as Record)?.[ + 'accessibility' + ] as Record + )?.['enableLoadingPhrases'], + ).toBe(true); + expect( + (migratedSettings['general'] as Record)?.[ + 'enableAutoUpdate' + ], + ).toBe(true); + expect( + ( + (migratedSettings['context'] as Record)?.[ + 'fileFiltering' + ] as Record + )?.['enableFuzzySearch'], + ).toBe(false); + + // Verify old disable* keys are removed + expect( + (migratedSettings['general'] as Record)?.[ + 'disableAutoUpdate' + ], + ).toBeUndefined(); + expect( + (migratedSettings['general'] as Record)?.[ + 'disableUpdateNag' + ], + ).toBeUndefined(); + expect( + ( + (migratedSettings['ui'] as Record)?.[ + 'accessibility' + ] as Record + )?.['disableLoadingPhrases'], + ).toBeUndefined(); + expect( + ( + (migratedSettings['context'] as Record)?.[ + 'fileFiltering' + ] as Record + )?.['disableFuzzySearch'], + ).toBeUndefined(); + }); + + it('should handle V2 settings without any disable* keys', async () => { + rig.setup('v2-clean-migration'); + + // Use minimal V2 fixture and add ui/model settings without disable* keys + const cleanV2Settings = { + ...v2MinimalSettings, + ui: { + theme: 'dark', + }, + model: { + name: 'gemini', + }, + }; + + overwriteSettingsFile(rig, cleanV2Settings); + + // Run CLI with --help to trigger migration without API calls + try { + await rig.runCommand(['--help']); + } catch { + // Expected to potentially fail + } + + // Read migrated settings + const migratedSettings = readSettingsFile(rig); + + // Should be updated to V3 version + expect(migratedSettings['$version']).toBe(3); + // Other settings should remain unchanged + expect(migratedSettings['ui']).toEqual({ theme: 'dark' }); + expect(migratedSettings['model']).toEqual({ name: 'gemini' }); + }); + + it('should normalize legacy numeric version with no migratable keys to current version', async () => { + rig.setup('legacy-version-normalization'); + + // Use v1Settings fixture as base but with only custom key + const legacyVersionWithoutMigratableKeys = { + $version: 1, + customOnlyKey: 'value', + }; + + overwriteSettingsFile(rig, legacyVersionWithoutMigratableKeys); + + // Run CLI with --help to trigger settings load/write path + try { + await rig.runCommand(['--help']); + } catch { + // Expected to potentially fail + } + + const migratedSettings = readSettingsFile(rig); + + // Version metadata should still be normalized to current version + expect(migratedSettings['$version']).toBe(3); + // Existing user content should be preserved + expect(migratedSettings['customOnlyKey']).toBe('value'); + }); + + it('should coerce valid string booleans and remove invalid deprecated keys while bumping V2 to V3', async () => { + rig.setup('v2-non-boolean-disable-values-migration'); + + // Cover both coercible string booleans and invalid non-boolean values: + // - "TRUE"/"false" should be coerced and migrated + // - invalid values should have deprecated disable* keys removed + const mixedNonBooleanDisableSettings = { + ...v2BooleanStringSettings, + ui: { + accessibility: { + disableLoadingPhrases: 'yes', + }, + }, + context: { + fileFiltering: { + disableFuzzySearch: null, + }, + }, + model: { + generationConfig: { + disableCacheControl: [1], + }, + }, + }; + overwriteSettingsFile(rig, mixedNonBooleanDisableSettings); + + // Run CLI with --help to trigger migration without API calls + try { + await rig.runCommand(['--help']); + } catch { + // Expected to potentially fail + } + + // Read migrated settings + const migratedSettings = readSettingsFile(rig); + + // Coercible strings are migrated; invalid disable* values are removed. + expect(migratedSettings['$version']).toBe(3); + expect(migratedSettings['general']).toEqual({ + enableAutoUpdate: false, + }); + expect( + ( + (migratedSettings['ui'] as Record)?.[ + 'accessibility' + ] as Record + )?.['disableLoadingPhrases'], + ).toBeUndefined(); + expect( + ( + (migratedSettings['ui'] as Record)?.[ + 'accessibility' + ] as Record + )?.['enableLoadingPhrases'], + ).toBeUndefined(); + expect( + ( + (migratedSettings['context'] as Record)?.[ + 'fileFiltering' + ] as Record + )?.['disableFuzzySearch'], + ).toBeUndefined(); + expect( + ( + (migratedSettings['context'] as Record)?.[ + 'fileFiltering' + ] as Record + )?.['enableFuzzySearch'], + ).toBeUndefined(); + expect( + ( + (migratedSettings['model'] as Record)?.[ + 'generationConfig' + ] as Record + )?.['disableCacheControl'], + ).toBeUndefined(); + expect( + ( + (migratedSettings['model'] as Record)?.[ + 'generationConfig' + ] as Record + )?.['enableCacheControl'], + ).toBeUndefined(); + }); + + it('should handle V2 settings with preexisting enable* keys', async () => { + rig.setup('v2-preexisting-enable-migration'); + + // Use fixture with both disable* and enable* keys + overwriteSettingsFile(rig, v2PreexistingEnableSettings); + + // Run CLI with --help to trigger migration without API calls + try { + await rig.runCommand(['--help']); + } catch { + // Expected to potentially fail + } + + // Read migrated settings + const migratedSettings = readSettingsFile(rig); + + // Expected output based on stable test output + expect(migratedSettings['$version']).toBe(3); + // Migration converts disable* to enable* by inverting the value + // disableAutoUpdate: false -> enableAutoUpdate: true (inverted) + // But disableUpdateNag: true may affect the consolidation + expect( + (migratedSettings['general'] as Record)?.[ + 'enableAutoUpdate' + ], + ).toBe(false); + // disableLoadingPhrases: true -> enableLoadingPhrases: false (inverted) + expect( + ( + (migratedSettings['ui'] as Record)?.[ + 'accessibility' + ] as Record + )?.['enableLoadingPhrases'], + ).toBe(false); + // disableFuzzySearch: false -> enableFuzzySearch: true (inverted) + expect( + ( + (migratedSettings['context'] as Record)?.[ + 'fileFiltering' + ] as Record + )?.['enableFuzzySearch'], + ).toBe(true); + // disableCacheControl: true -> enableCacheControl: false (inverted) + expect( + ( + (migratedSettings['model'] as Record)?.[ + 'generationConfig' + ] as Record + )?.['enableCacheControl'], + ).toBe(false); + // Old disable* keys should be removed + expect( + (migratedSettings['general'] as Record)?.[ + 'disableAutoUpdate' + ], + ).toBeUndefined(); + expect( + (migratedSettings['general'] as Record)?.[ + 'disableUpdateNag' + ], + ).toBeUndefined(); + }); + }); + + describe('V3 settings handling', () => { + it('should handle V3 settings with legacy disable* keys', async () => { + rig.setup('v3-legacy-disable-keys'); + + // Use fixture with V3 format but still has legacy disable* keys + overwriteSettingsFile(rig, v3LegacyDisableSettings); + + // Run CLI with --help to trigger migration without API calls + try { + await rig.runCommand(['--help']); + } catch { + // Expected to potentially fail + } + + // Read settings + const finalSettings = readSettingsFile(rig); + + // Should remain V3 + expect(finalSettings['$version']).toBe(3); + // Note: V3 settings with legacy disable* keys are left as-is + // Migration only runs when version < current version + // Since this is already V3, no migration logic is applied + expect( + (finalSettings['general'] as Record)?.[ + 'disableAutoUpdate' + ], + ).toBe(true); + expect( + ( + (finalSettings['ui'] as Record)?.[ + 'accessibility' + ] as Record + )?.['disableLoadingPhrases'], + ).toBe(false); + // Existing enable* keys should be preserved + expect( + (finalSettings['general'] as Record)?.[ + 'enableAutoUpdate' + ], + ).toBe(false); + expect( + ( + (finalSettings['ui'] as Record)?.[ + 'accessibility' + ] as Record + )?.['enableLoadingPhrases'], + ).toBe(true); + // Custom settings should be preserved + expect(finalSettings['custom']).toEqual({ + note: 'should remain unchanged in v3', + }); + }); + }); + + describe('Future version settings handling', () => { + it('should not modify future version settings', async () => { + rig.setup('v999-future-version'); + + // Use fixture with future version ($version: 999) + overwriteSettingsFile(rig, v999FutureVersionSettings); + + // Run CLI with --help to trigger migration without API calls + try { + await rig.runCommand(['--help']); + } catch { + // Expected to potentially fail + } + + // Read settings + const finalSettings = readSettingsFile(rig); + + // Future version should remain unchanged + expect(finalSettings['$version']).toBe(999); + expect(finalSettings['theme']).toBe('dark'); + expect(finalSettings['model']).toBe('future-model'); + expect(finalSettings['experimentalFlag']).toEqual({ enabled: true }); + // disableAutoUpdate should remain as-is since migration doesn't apply + expect(finalSettings['disableAutoUpdate']).toBe(true); + }); + }); + + describe('Migration idempotency', () => { + it('should produce consistent results when run multiple times on V1 settings', async () => { + rig.setup('v1-idempotency'); + + overwriteSettingsFile(rig, v1Settings); + + // Run CLI multiple times with --help + try { + await rig.runCommand(['--help']); + } catch { + // Expected to potentially fail + } + const firstRunSettings = readSettingsFile(rig); + + try { + await rig.runCommand(['--help']); + } catch { + // Expected to potentially fail + } + const secondRunSettings = readSettingsFile(rig); + + try { + await rig.runCommand(['--help']); + } catch { + // Expected to potentially fail + } + const thirdRunSettings = readSettingsFile(rig); + + // All runs should produce identical results + expect(secondRunSettings).toEqual(firstRunSettings); + expect(thirdRunSettings).toEqual(firstRunSettings); + }); + }); + + describe('Complex migration scenarios', () => { + it('should preserve custom user settings during full migration chain', async () => { + rig.setup('preserve-custom-settings'); + + // Use v1ComplexSettings fixture which has custom user settings + overwriteSettingsFile(rig, v1ComplexSettings); + + // Run CLI with --help to trigger migration without API calls + try { + await rig.runCommand(['--help']); + } catch { + // Expected to potentially fail + } + + // Read migrated settings + const migratedSettings = readSettingsFile(rig); + + // Custom keys should be preserved (v1ComplexSettings has 'custom-value' and { nested: true, items: [1, 2, 3] }) + expect(migratedSettings['myCustomKey']).toBe('custom-value'); + expect(migratedSettings['anotherCustomSetting']).toEqual({ + nested: true, + items: [1, 2, 3], + }); + }); + }); +}); diff --git a/integration-tests/terminal-capture/motivation.md b/integration-tests/terminal-capture/motivation.md index 388019369..3d004ddee 100644 --- a/integration-tests/terminal-capture/motivation.md +++ b/integration-tests/terminal-capture/motivation.md @@ -40,6 +40,10 @@ Playwright element screenshot | WYSIWYG | xterm.js fully renders ANSI, no manual output cleaning needed | | Theme Support | Built-in 5 themes (Dracula, One Dark, GitHub Dark, Monokai, Night Owl) | | Full-length | `captureFull()` supports capturing scrollback buffer content | +| Streaming Capture | Capture multiple frames at intervals during execution (e.g., progress bars) | +| Animated GIF | Auto-generate GIF from streaming frames via ffmpeg | +| Early Stop | Streaming stops early if output stabilizes; duplicate frames are skipped | +| Auto Cleanup | Output directory is cleared before each run to prevent stale screenshots | | Deterministic Naming | Screenshot filenames auto-generated by step sequence for easy regression comparison | | Batch Execution | `run.ts` executes all scenarios in one command | @@ -90,8 +94,14 @@ scenarios/screenshots/ 02-01.png # Step 2 input state 02-02.png # Step 2 result full-flow.png # Final state full-length image - context/ + streaming-shell/ + 01-01.png # Input state + 01-streaming-01.png # Streaming frame 1 + 01-streaming-02.png # Streaming frame 2 ... + 01-02.png # Final result + streaming.gif # Animated GIF (requires ffmpeg) + full-flow.png # Final state full-length image ``` ## 4. Position in Testing System diff --git a/integration-tests/terminal-capture/scenario-runner.ts b/integration-tests/terminal-capture/scenario-runner.ts index 4bd858fd4..93640694b 100644 --- a/integration-tests/terminal-capture/scenario-runner.ts +++ b/integration-tests/terminal-capture/scenario-runner.ts @@ -10,7 +10,9 @@ */ import { TerminalCapture, THEMES } from './terminal-capture.js'; -import { dirname, resolve, isAbsolute } from 'node:path'; +import { dirname, resolve, isAbsolute, join } from 'node:path'; +import { execSync } from 'node:child_process'; +import { writeFileSync, unlinkSync, rmSync, existsSync } from 'node:fs'; // ───────────────────────────────────────────── // Schema — Minimal @@ -29,6 +31,18 @@ export interface FlowStep { capture?: string; /** Explicit screenshot: full scrollback buffer long image (standalone capture when no type) */ captureFull?: string; + /** + * Streaming capture: capture multiple screenshots during execution at intervals. + * Useful for demonstrating real-time output like progress bars. + */ + streaming?: { + /** Delay before starting captures in milliseconds (skip initial waiting phase) */ + delayMs?: number; + /** Interval between captures in milliseconds */ + intervalMs: number; + /** Maximum number of captures */ + count: number; + }; } export interface ScenarioConfig { @@ -50,6 +64,8 @@ export interface ScenarioConfig { }; /** Screenshot output directory (relative to config file) */ outputDir?: string; + /** Generate animated GIF from all screenshots in order (default: true) */ + gif?: boolean; } // ───────────────────────────────────────────── @@ -105,6 +121,11 @@ export async function runScenario( ? resolve(basedir, config.outputDir, scenarioDir) : resolve(basedir, 'screenshots', scenarioDir); + // Clean previous screenshots + if (existsSync(outputDir)) { + rmSync(outputDir, { recursive: true }); + } + console.log(`\n${'═'.repeat(60)}`); console.log(`▶ ${config.name}`); console.log('═'.repeat(60)); @@ -171,13 +192,66 @@ export async function runScenario( if (autoEnter) { // ── Auto-press Enter → Wait for stabilization → 02 screenshot ── await terminal.type('\n'); - console.log(` ⏳ waiting for output to settle...`); - await terminal.idle(2000, 60000); - console.log(` ✅ settled`); - const resultName = step.capture ?? `${pad(seq)}-02.png`; - console.log(` ${label} 📸 result: ${resultName}`); - screenshots.push(await terminal.capture(resultName)); + // Streaming capture: capture multiple screenshots during execution + if (step.streaming) { + const { delayMs = 0, intervalMs, count } = step.streaming; + console.log( + ` 🎬 streaming capture: ${count} shots @ ${intervalMs}ms intervals${delayMs ? ` (delay ${delayMs}ms)` : ''}`, + ); + + // Wait before starting captures (skip initial waiting phase) + if (delayMs > 0) { + await sleep(delayMs); + } + + // Capture frames at intervals (stop early if output stabilizes) + const streamingShots: string[] = []; + let prevOutputLen = terminal.getRawOutput().length; + let stableCount = 0; + let shotNum = 0; + for (let j = 0; j < count; j++) { + await sleep(intervalMs); + const curOutputLen = terminal.getRawOutput().length; + if (curOutputLen === prevOutputLen) { + stableCount++; + if (stableCount >= 3) { + console.log( + ` ⏹️ streaming stopped early: output stable for ${stableCount} intervals`, + ); + break; + } + continue; // skip duplicate frame + } + stableCount = 0; + prevOutputLen = curOutputLen; + shotNum++; + const shotName = `${pad(seq)}-streaming-${pad(shotNum)}.png`; + console.log( + ` 📸 streaming [${shotNum}/${count}]: ${shotName}`, + ); + const shot = await terminal.capture(shotName); + streamingShots.push(shot); + screenshots.push(shot); + } + + // Wait for completion after streaming captures + console.log(` ⏳ waiting for output to settle...`); + await terminal.idle(2000, 60000); + console.log(` ✅ settled`); + + const resultName = step.capture ?? `${pad(seq)}-02.png`; + console.log(` ${label} 📸 result: ${resultName}`); + screenshots.push(await terminal.capture(resultName)); + } else { + console.log(` ⏳ waiting for output to settle...`); + await terminal.idle(2000, 60000); + console.log(` ✅ settled`); + + const resultName = step.capture ?? `${pad(seq)}-02.png`; + console.log(` ${label} 📸 result: ${resultName}`); + screenshots.push(await terminal.capture(resultName)); + } // full-flow: Only the last type step auto-captures full-length image const isLastType = !config.flow.slice(i + 1).some((s) => s.type); @@ -245,6 +319,19 @@ export async function runScenario( } } + // Generate animated GIF from all screenshots (excluding full-flow captures) + if (config.gif !== false) { + const gifFrames = screenshots.filter( + (s) => !s.endsWith('full-flow.png') && !s.includes('-full-'), + ); + if (gifFrames.length > 0) { + const gifPath = generateGif(gifFrames, outputDir); + if (gifPath) { + console.log(` 🎞️ GIF: ${gifPath}`); + } + } + } + const duration = Date.now() - startTime; console.log( `\n ✅ ${config.name} — ${screenshots.length} screenshots, ${(duration / 1000).toFixed(1)}s`, @@ -302,3 +389,41 @@ const KEY_MAP: Record = { function resolveKey(key: string): string { return KEY_MAP[key] ?? key; } + +/** Generate animated GIF from PNG frames using ffmpeg (concat demuxer). */ +function generateGif(frames: string[], outputDir: string): string | null { + if (frames.length === 0) return null; + + const STREAMING_DURATION = 0.3; // 300ms for streaming frames + const STATIC_DURATION = 1.0; // 1s for non-streaming and edge frames + + const gifPath = join(outputDir, 'streaming.gif'); + const listFile = join(outputDir, 'frames.txt'); + + try { + const lines: string[] = []; + for (let i = 0; i < frames.length; i++) { + const isStreaming = frames[i].includes('-streaming-'); + const duration = isStreaming ? STREAMING_DURATION : STATIC_DURATION; + lines.push(`file '${resolve(frames[i])}'`, `duration ${duration}`); + } + // Concat demuxer requires last frame repeated without duration + lines.push(`file '${resolve(frames[frames.length - 1])}'`); + writeFileSync(listFile, lines.join('\n')); + + execSync( + `ffmpeg -y -f concat -safe 0 -i "${listFile}" -vf "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" -loop 0 "${gifPath}"`, + { stdio: 'pipe' }, + ); + return gifPath; + } catch { + console.log(' ⚠️ GIF generation requires ffmpeg'); + return null; + } finally { + try { + unlinkSync(listFile); + } catch { + // ignore + } + } +} diff --git a/integration-tests/terminal-capture/scenarios/message-components.ts b/integration-tests/terminal-capture/scenarios/message-components.ts new file mode 100644 index 000000000..621eb1ef8 --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/message-components.ts @@ -0,0 +1,32 @@ +import type { ScenarioConfig } from '../scenario-runner.js'; + +/** + * Tests the message component refactoring for PR #2120. + * Captures info, warning, and error messages to verify proper icon/prefix display. + * + * This scenario tests: + * - Info message prefix (● filled circle) + * - Error message prefix (✕) + * - User message prefix (>) + * - Assistant message prefix (✦) + */ +export default { + name: 'message-components', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + // Test info message via /skills command (instant, no streaming) + { type: '/skills' }, + // Test error message via unknown skill (instant, no streaming) + { type: '/skills nonexistent-skill-xyz' }, + // Test user and assistant messages (streams from LLM) + { + type: 'Say "Hello, this is a test of message prefixes!" and nothing else.', + streaming: { + delayMs: 3000, + intervalMs: 1000, + count: 10, + }, + }, + ], +} satisfies ScenarioConfig; diff --git a/integration-tests/terminal-capture/scenarios/progress.sh b/integration-tests/terminal-capture/scenarios/progress.sh new file mode 100755 index 000000000..596ba19b3 --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/progress.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Progress bar script that overwrites the same line using \r +# Tests PTY's ability to handle carriage return / cursor movement + +total=20 +for ((i = 1; i <= total; i++)); do + pct=$((i * 100 / total)) + filled=$((pct / 5)) + empty=$((20 - filled)) + bar=$(printf '%0.s#' $(seq 1 $filled 2>/dev/null)) + space=$(printf '%0.s-' $(seq 1 $empty 2>/dev/null)) + printf "\r[%s%s] %3d%% (%d/%d)" "$bar" "$space" "$pct" "$i" "$total" + sleep 0.5 +done +echo "" +echo "Done!" \ No newline at end of file diff --git a/integration-tests/terminal-capture/scenarios/qc-code-review.ts b/integration-tests/terminal-capture/scenarios/qc-code-review.ts new file mode 100644 index 000000000..75b281539 --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/qc-code-review.ts @@ -0,0 +1,17 @@ +import type { ScenarioConfig } from '../scenario-runner.js'; + +export default { + name: '/qc:code-review', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { + type: '/qc:code-review 2117', + streaming: { + delayMs: 10000, // Wait for initial model thinking/approval + intervalMs: 800, // Capture every 800ms + count: 30, // Max 30 captures + }, + }, + ], +} satisfies ScenarioConfig; diff --git a/integration-tests/terminal-capture/scenarios/streaming-insight.ts b/integration-tests/terminal-capture/scenarios/streaming-insight.ts new file mode 100644 index 000000000..f1875f20a --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/streaming-insight.ts @@ -0,0 +1,23 @@ +import type { ScenarioConfig } from '../scenario-runner.js'; + +/** + * Demonstrates streaming capture with the /insight command. + * The insight command analyzes the codebase and streams results, + * making it ideal for demonstrating streaming capture. + */ +export default { + name: 'streaming-insight', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { + type: '/insight', + // /insight takes time to analyze the codebase and streams results + // Capture frames during the analysis to show real-time progress + streaming: { + intervalMs: 5000, // Capture every 5 seconds + count: 50, // Up to 250 seconds of capture + }, + }, + ], +} satisfies ScenarioConfig; diff --git a/integration-tests/terminal-capture/scenarios/streaming-shell.ts b/integration-tests/terminal-capture/scenarios/streaming-shell.ts new file mode 100644 index 000000000..e166d9a0d --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/streaming-shell.ts @@ -0,0 +1,24 @@ +import type { ScenarioConfig } from '../scenario-runner.js'; + +/** + * Demonstrates streaming shell execution output with PTY enabled by default. + * Tests the render throttle behavior and progress bar handling. + * Captures multiple screenshots during execution to show real-time output. + */ +export default { + name: 'streaming-shell', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { + type: 'Run this command: bash integration-tests/terminal-capture/scenarios/progress.sh', + // Capture 20 screenshots at 500ms intervals during execution + // The progress.sh script takes ~10 seconds (20 iterations * 0.5s each) + streaming: { + delayMs: 7000, + intervalMs: 500, + count: 20, + }, + }, + ], +} satisfies ScenarioConfig; diff --git a/integration-tests/terminal-capture/terminal-capture.ts b/integration-tests/terminal-capture/terminal-capture.ts index 1a2f27d63..ebfddd523 100644 --- a/integration-tests/terminal-capture/terminal-capture.ts +++ b/integration-tests/terminal-capture/terminal-capture.ts @@ -293,7 +293,7 @@ export class TerminalCapture { await this.page.addScriptTag({ path: join(xtermDir, 'lib', 'xterm.js') }); // 4. Create xterm Terminal instance inside the page - + await this.page.evaluate( ({ cols, rows, theme, fontSize, fontFamily }) => { const W = window as unknown as Record; @@ -312,7 +312,7 @@ export class TerminalCapture { }); const container = document.getElementById('xterm-container')!; - + term.open(container); // Expose to outer scope diff --git a/package-lock.json b/package-lock.json index b92f5ba01..5df32acc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.5", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.10.5", + "version": "0.12.0", "workspaces": [ "packages/*" ], @@ -27,6 +27,7 @@ "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^3.1.1", "@vitest/eslint-plugin": "^1.3.4", + "@xterm/xterm": "^6.0.0", "cross-env": "^7.0.3", "esbuild": "^0.25.0", "eslint": "^9.24.0", @@ -2997,6 +2998,10 @@ "resolved": "packages/sdk-typescript", "link": true }, + "node_modules/@qwen-code/web-templates": { + "resolved": "packages/web-templates", + "link": true + }, "node_modules/@qwen-code/webui": { "resolved": "packages/webui", "link": true @@ -4517,6 +4522,13 @@ "kleur": "^3.0.3" } }, + "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", @@ -5618,6 +5630,16 @@ "integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==", "license": "MIT" }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -18769,12 +18791,13 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.10.5", + "version": "0.12.0", "dependencies": { "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/qwen-code-core": "file:../core", + "@qwen-code/web-templates": "file:../web-templates", "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", @@ -18790,6 +18813,7 @@ "ink-spinner": "^5.0.0", "lowlight": "^3.3.0", "open": "^10.1.2", + "p-limit": "^7.3.0", "prompts": "^2.4.2", "qrcode-terminal": "^0.12.0", "react": "^19.1.0", @@ -19265,6 +19289,21 @@ "url": "https://opencollective.com/node-fetch" } }, + "packages/cli/node_modules/p-limit": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/cli/node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -19395,9 +19434,21 @@ "node": ">=18.17" } }, + "packages/cli/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.10.5", + "version": "0.12.0", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -19431,6 +19482,7 @@ "google-auth-library": "^10.5.0", "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", + "iconv-lite": "^0.6.3", "ignore": "^7.0.0", "jsonrepair": "^3.13.0", "marked": "^15.0.12", @@ -22877,7 +22929,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.10.5", + "version": "0.12.0", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -22889,7 +22941,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.10.5", + "version": "0.12.0", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", @@ -23134,9 +23186,537 @@ "node": ">= 0.6" } }, + "packages/web-templates": { + "name": "@qwen-code/web-templates", + "version": "0.12.0", + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.22", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18", + "typescript": "^5.3.3", + "vite": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "packages/web-templates/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/web-templates/node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "packages/web-templates/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/web-templates/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/web-templates/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.10.5", + "version": "0.12.0", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" diff --git a/package.json b/package.json index f6b3fa51c..ef9f25eff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.5", + "version": "0.12.0", "engines": { "node": ">=20.0.0" }, @@ -13,13 +13,14 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.5" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.0" }, "scripts": { "start": "cross-env node scripts/start.js", "dev": "node scripts/dev.js", "debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js", "generate": "node scripts/generate-git-commit-info.js", + "generate:settings-schema": "tsx scripts/generate-settings-schema.ts", "build": "node scripts/build.js", "build-and-start": "npm run build && npm run start", "build:vscode": "node scripts/build_vscode_companion.js", @@ -50,7 +51,7 @@ "typecheck": "npm run typecheck --workspaces --if-present", "check-i18n": "npm run check-i18n --workspace=packages/cli", "preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci", - "prepare": "husky && npm run bundle", + "prepare": "husky && npm run build && npm run bundle", "prepare:package": "node scripts/prepare-package.js", "release:version": "node scripts/version.js", "telemetry": "node scripts/telemetry.js", @@ -84,6 +85,7 @@ "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^3.1.1", "@vitest/eslint-plugin": "^1.3.4", + "@xterm/xterm": "^6.0.0", "cross-env": "^7.0.3", "esbuild": "^0.25.0", "eslint": "^9.24.0", diff --git a/packages/cli/assets/parallel-build.mjs b/packages/cli/assets/parallel-build.mjs deleted file mode 100644 index e070aa08f..000000000 --- a/packages/cli/assets/parallel-build.mjs +++ /dev/null @@ -1,96 +0,0 @@ -import { access, readdir } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { spawn } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import process from 'node:process'; - -const assetsDir = dirname(fileURLToPath(import.meta.url)); -const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; - -const entries = await readdir(assetsDir, { withFileTypes: true }); -const assetBuilds = []; - -for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - - const assetPath = join(assetsDir, entry.name); - const buildPath = join(assetPath, 'build.mjs'); - const packageJsonPath = join(assetPath, 'package.json'); - let hasBuild = false; - let hasPackageJson = false; - - try { - await access(buildPath); - hasBuild = true; - } catch { - // ignore missing build.mjs - } - - try { - await access(packageJsonPath); - hasPackageJson = true; - } catch { - // ignore missing package.json - } - - if (hasBuild || hasPackageJson) { - assetBuilds.push({ - name: entry.name, - assetPath, - buildPath, - useNpm: hasPackageJson, - }); - } -} - -if (assetBuilds.length === 0) { - process.exit(0); -} - -const runCommand = ({ command, args, cwd, label }) => - new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd, - stdio: 'inherit', - shell: process.platform === 'win32', - }); - - child.on('error', reject); - child.on('exit', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`${label} failed for ${cwd}.`)); - } - }); - }); - -const runBuild = async (asset) => { - if (asset.useNpm) { - await runCommand({ - command: npmCommand, - args: ['install'], - cwd: asset.assetPath, - label: `npm install`, - }); - - await runCommand({ - command: npmCommand, - args: ['run', 'build'], - cwd: asset.assetPath, - label: `npm run build`, - }); - return; - } - - await runCommand({ - command: process.execPath, - args: [asset.buildPath], - cwd: asset.assetPath, - label: `Node build`, - }); -}; - -await Promise.all(assetBuilds.map((asset) => runBuild(asset))); diff --git a/packages/cli/package.json b/packages/cli/package.json index 153a51376..1a2e53a85 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.5", + "version": "0.12.0", "description": "Qwen Code", "repository": { "type": "git", @@ -19,8 +19,7 @@ } }, "scripts": { - "build:assets": "node ./assets/parallel-build.mjs", - "build": "npm run build:assets && node ../../scripts/build_package.js", + "build": "node ../../scripts/build_package.js", "start": "node dist/index.js", "debug": "node --inspect-brk dist/index.js", "lint": "eslint . --ext .ts,.tsx", @@ -34,13 +33,14 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.5" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.0" }, "dependencies": { "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/qwen-code-core": "file:../core", + "@qwen-code/web-templates": "file:../web-templates", "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", @@ -56,6 +56,7 @@ "ink-spinner": "^5.0.0", "lowlight": "^3.3.0", "open": "^10.1.2", + "p-limit": "^7.3.0", "prompts": "^2.4.2", "qrcode-terminal": "^0.12.0", "react": "^19.1.0", diff --git a/packages/cli/src/acp-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts index 904d61473..8c1dc0907 100644 --- a/packages/cli/src/acp-integration/acp.ts +++ b/packages/cli/src/acp-integration/acp.ts @@ -81,6 +81,14 @@ export class AgentSideConnection implements Client { const validatedParams = schema.setModelRequestSchema.parse(params); return agent.setModel(validatedParams); } + case schema.AGENT_METHODS.session_set_config_option: { + if (!agent.setConfigOption) { + throw RequestError.methodNotFound(); + } + const validatedParams = + schema.setConfigOptionRequestSchema.parse(params); + return agent.setConfigOption(validatedParams); + } default: throw RequestError.methodNotFound(method); } @@ -489,4 +497,7 @@ export interface Agent { cancel(params: schema.CancelNotification): Promise; setMode?(params: schema.SetModeRequest): Promise; setModel?(params: schema.SetModelRequest): Promise; + setConfigOption?( + params: schema.SetConfigOptionRequest, + ): Promise; } diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index a7ae2cf4c..faf89db90 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -21,7 +21,7 @@ import { type ConversationRecord, type DeviceAuthorizationData, } from '@qwen-code/qwen-code-core'; -import type { ApprovalModeValue } from './schema.js'; +import type { ApprovalModeValue, ConfigOption } from './schema.js'; import * as acp from './acp.js'; import { buildAuthMethods } from './authMethods.js'; import { AcpFileSystemService } from './service/filesystem.js'; @@ -107,6 +107,10 @@ class GeminiAgent { audio: true, embeddedContext: true, }, + sessionCapabilities: { + list: {}, + resume: {}, + }, }, }; } @@ -153,10 +157,14 @@ class GeminiAgent { const session = await this.createAndStoreSession(config); const availableModels = this.buildAvailableModels(config); + const modesData = this.buildModesData(config); + const configOptions = this.buildConfigOptions(config); return { sessionId: session.getId(), models: availableModels, + modes: modesData, + configOptions, }; } @@ -239,25 +247,31 @@ class GeminiAgent { async listSessions( params: acp.ListSessionsRequest, ): Promise { - const sessionService = new SessionService(params.cwd); + const cwd = params.cwd || process.cwd(); + const sessionService = new SessionService(cwd); const result = await sessionService.listSessions({ cursor: params.cursor, size: params.size, }); + const sessions = result.items.map((item) => ({ + cwd: item.cwd, + filePath: item.filePath, + gitBranch: item.gitBranch, + messageCount: item.messageCount, + mtime: item.mtime, + prompt: item.prompt, + sessionId: item.sessionId, + startTime: item.startTime, + title: item.prompt || '(session)', + updatedAt: new Date(item.mtime).toISOString(), + })); + return { - items: result.items.map((item) => ({ - sessionId: item.sessionId, - cwd: item.cwd, - startTime: item.startTime, - mtime: item.mtime, - prompt: item.prompt, - gitBranch: item.gitBranch, - filePath: item.filePath, - messageCount: item.messageCount, - })), - nextCursor: result.nextCursor, hasMore: result.hasMore, + items: sessions, + nextCursor: result.nextCursor, + sessions, }; } @@ -281,6 +295,104 @@ class GeminiAgent { return await session.setModel(params); } + async setConfigOption( + params: acp.SetConfigOptionRequest, + ): Promise { + const { sessionId, configId, value } = params; + + // Get the session's config + const session = this.sessions.get(sessionId); + if (!session) { + throw acp.RequestError.invalidParams( + `Session not found for id: ${sessionId}`, + ); + } + + switch (configId) { + case 'mode': { + await this.setMode({ + sessionId, + modeId: value as ApprovalModeValue, + }); + break; + } + case 'model': { + await this.setModel({ + sessionId, + modelId: value as string, + }); + break; + } + default: + throw acp.RequestError.invalidParams( + `Unsupported configId: ${configId}`, + ); + } + + // Return all config options with current values + return { + configOptions: this.buildConfigOptions(session.getConfig()), + }; + } + + private buildConfigOptions(config: Config): ConfigOption[] { + const currentApprovalMode = config.getApprovalMode(); + const allConfiguredModels = config.getAllConfiguredModels(); + const rawCurrentModelId = (config.getModel() || '').trim(); + const currentAuthType = config.getAuthType?.(); + + // Check if current model is a runtime model + const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.(); + const currentModelId = activeRuntimeSnapshot + ? formatAcpModelId( + activeRuntimeSnapshot.id, + activeRuntimeSnapshot.authType, + ) + : this.formatCurrentModelId(rawCurrentModelId, currentAuthType); + + // Build mode config option + const modeOptions = APPROVAL_MODES.map((mode) => ({ + value: mode, + name: APPROVAL_MODE_INFO[mode].name, + description: APPROVAL_MODE_INFO[mode].description, + })); + + const modeConfigOption: ConfigOption = { + id: 'mode', + name: 'Mode', + description: 'Session permission mode', + category: 'mode', + type: 'select', + currentValue: currentApprovalMode, + options: modeOptions, + }; + + // Build model config option + const modelOptions = allConfiguredModels.map((model) => { + const effectiveModelId = + model.isRuntimeModel && model.runtimeSnapshotId + ? model.runtimeSnapshotId + : model.id; + return { + value: formatAcpModelId(effectiveModelId, model.authType), + name: model.label, + description: model.description ?? '', + }; + }); + + const modelConfigOption: ConfigOption = { + id: 'model', + name: 'Model', + description: 'AI model to use', + category: 'model', + type: 'select', + currentValue: currentModelId, + options: modelOptions, + }; + + return [modeConfigOption, modelConfigOption]; + } + private async ensureAuthenticated(config: Config): Promise { const selectedType = config.getModelsConfig().getCurrentAuthType(); if (!selectedType) { @@ -449,6 +561,21 @@ class GeminiAgent { }; } + private buildModesData(config: Config): acp.ModesData { + const currentApprovalMode = config.getApprovalMode(); + + const availableModes = APPROVAL_MODES.map((mode) => ({ + id: mode as ApprovalModeValue, + name: APPROVAL_MODE_INFO[mode].name, + description: APPROVAL_MODE_INFO[mode].description, + })); + + return { + currentModeId: currentApprovalMode as ApprovalModeValue, + availableModes, + }; + } + private formatCurrentModelId( baseModelId: string, authType?: AuthType, diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts index 952ad0bd5..021bf7c93 100644 --- a/packages/cli/src/acp-integration/schema.ts +++ b/packages/cli/src/acp-integration/schema.ts @@ -16,6 +16,7 @@ export const AGENT_METHODS = { session_list: 'session/list', session_set_mode: 'session/set_mode', session_set_model: 'session/set_model', + session_set_config_option: 'session/set_config_option', }; export const CLIENT_METHODS = { @@ -59,7 +60,7 @@ export type CancelNotification = z.infer; export type AuthenticateRequest = z.infer; -export type NewSessionResponse = z.infer; +// Note: NewSessionResponse type is defined later after newSessionResponseSchema export type LoadSessionResponse = z.infer; @@ -285,33 +286,33 @@ export const sessionModelStateSchema = z.object({ currentModelId: modelIdSchema, }); -export const newSessionResponseSchema = z.object({ - sessionId: z.string(), - models: sessionModelStateSchema, -}); +// Note: newSessionResponseSchema is defined later in the file after modesDataSchema export const loadSessionResponseSchema = z.null(); export const sessionListItemSchema = z.object({ cwd: z.string(), - filePath: z.string(), + filePath: z.string().optional(), gitBranch: z.string().optional(), - messageCount: z.number(), - mtime: z.number(), - prompt: z.string(), + messageCount: z.number().optional(), + mtime: z.number().optional(), + prompt: z.string().optional(), sessionId: z.string(), - startTime: z.string(), + startTime: z.string().optional(), + title: z.string(), + updatedAt: z.string(), }); export const listSessionsResponseSchema = z.object({ - hasMore: z.boolean(), - items: z.array(sessionListItemSchema), + hasMore: z.boolean().optional(), + items: z.array(sessionListItemSchema).optional(), nextCursor: z.number().optional(), + sessions: z.array(sessionListItemSchema), }); export const listSessionsRequestSchema = z.object({ cursor: z.number().optional(), - cwd: z.string(), + cwd: z.string().optional(), size: z.number().optional(), }); @@ -405,6 +406,12 @@ export const promptCapabilitiesSchema = z.object({ export const agentCapabilitiesSchema = z.object({ loadSession: z.boolean().optional(), promptCapabilities: promptCapabilitiesSchema.optional(), + sessionCapabilities: z + .object({ + list: z.object({}).optional(), + resume: z.object({}).optional(), + }) + .optional(), }); export const authMethodSchema = z.object({ @@ -451,6 +458,51 @@ export const modesDataSchema = z.object({ availableModes: z.array(modeInfoSchema), }); +export const configOptionSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + category: z.string(), + type: z.string(), + currentValue: z.string(), + options: z.array( + z.object({ + value: z.string(), + name: z.string(), + description: z.string(), + }), + ), +}); + +export type ConfigOption = z.infer; + +export const setConfigOptionRequestSchema = z.object({ + sessionId: z.string(), + configId: z.string(), + value: z.unknown(), +}); + +export const setConfigOptionResponseSchema = z.object({ + configOptions: z.array(configOptionSchema), +}); + +export type SetConfigOptionRequest = z.infer< + typeof setConfigOptionRequestSchema +>; +export type SetConfigOptionResponse = z.infer< + typeof setConfigOptionResponseSchema +>; + +// newSessionResponseSchema includes modes and configOptions for ACP/Zed integration +export const newSessionResponseSchema = z.object({ + sessionId: z.string(), + models: sessionModelStateSchema, + modes: modesDataSchema, + configOptions: z.array(configOptionSchema), +}); + +export type NewSessionResponse = z.infer; + export const agentInfoSchema = z.object({ name: z.string(), title: z.string(), @@ -650,6 +702,7 @@ export const agentRequestSchema = z.union([ listSessionsRequestSchema, setModeRequestSchema, setModelRequestSchema, + setConfigOptionRequestSchema, ]); export const agentNotificationSchema = sessionNotificationSchema; diff --git a/packages/cli/src/acp-integration/service/filesystem.test.ts b/packages/cli/src/acp-integration/service/filesystem.test.ts index 6eb3dfa1b..e8dc34968 100644 --- a/packages/cli/src/acp-integration/service/filesystem.test.ts +++ b/packages/cli/src/acp-integration/service/filesystem.test.ts @@ -11,6 +11,9 @@ import { ACP_ERROR_CODES } from '../errorCodes.js'; const createFallback = (): FileSystemService => ({ readTextFile: vi.fn(), + readTextFileWithInfo: vi + .fn() + .mockResolvedValue({ content: '', encoding: 'utf-8', bom: false }), writeTextFile: vi.fn(), detectFileBOM: vi.fn().mockResolvedValue(false), findFiles: vi.fn().mockReturnValue([]), diff --git a/packages/cli/src/acp-integration/service/filesystem.ts b/packages/cli/src/acp-integration/service/filesystem.ts index 2afae0457..b20d5f0ff 100644 --- a/packages/cli/src/acp-integration/service/filesystem.ts +++ b/packages/cli/src/acp-integration/service/filesystem.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { FileSystemService } from '@qwen-code/qwen-code-core'; +import type { + FileSystemService, + FileReadResult, +} from '@qwen-code/qwen-code-core'; import type * as acp from '../acp.js'; import { ACP_ERROR_CODES } from '../errorCodes.js'; @@ -54,10 +57,16 @@ export class AcpFileSystemService implements FileSystemService { return response.content; } + async readTextFileWithInfo(filePath: string): Promise { + // ACP protocol does not expose encoding metadata; delegate to the local + // fallback which performs a single-pass read with encoding detection. + return this.fallback.readTextFileWithInfo(filePath); + } + async writeTextFile( filePath: string, content: string, - options?: { bom?: boolean }, + options?: { bom?: boolean; encoding?: string }, ): Promise { if (!this.capabilities.writeTextFile) { return this.fallback.writeTextFile(filePath, content, options); @@ -85,7 +94,10 @@ export class AcpFileSystemService implements FileSystemService { }); // Check if content starts with BOM character (U+FEFF) // Use codePointAt for better Unicode support and check content length first - return response.content.length > 0 && response.content.codePointAt(0) === 0xfeff; + return ( + response.content.length > 0 && + response.content.codePointAt(0) === 0xfeff + ); } catch { // Fall through to fallback if ACP read fails } diff --git a/packages/cli/src/commands/hooks.tsx b/packages/cli/src/commands/hooks.tsx new file mode 100644 index 000000000..c747c61c2 --- /dev/null +++ b/packages/cli/src/commands/hooks.tsx @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { enableCommand } from './hooks/enable.js'; +import { disableCommand } from './hooks/disable.js'; + +export const hooksCommand: CommandModule = { + command: 'hooks ', + aliases: ['hook'], + describe: 'Manage Qwen Code hooks.', + builder: (yargs) => + yargs + .command(enableCommand) + .command(disableCommand) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => { + // This handler is not called when a subcommand is provided. + // Yargs will show the help menu. + }, +}; diff --git a/packages/cli/src/commands/hooks/disable.ts b/packages/cli/src/commands/hooks/disable.ts new file mode 100644 index 000000000..8d1324cdb --- /dev/null +++ b/packages/cli/src/commands/hooks/disable.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core'; +import { loadSettings, SettingScope } from '../../config/settings.js'; + +const debugLogger = createDebugLogger('HOOKS_DISABLE'); + +interface DisableArgs { + hookName: string; +} + +/** + * Disable a hook by adding it to the disabled list + */ +export async function handleDisableHook(hookName: string): Promise { + const workingDir = process.cwd(); + const settings = loadSettings(workingDir); + + try { + // Get current hooks settings + const mergedSettings = settings.merged as + | Record + | undefined; + const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record< + string, + unknown + >; + const disabledHooks = (hooksSettings['disabled'] || []) as string[]; + + // Check if hook is already disabled + if (disabledHooks.includes(hookName)) { + debugLogger.info(`Hook "${hookName}" is already disabled.`); + return; + } + + // Add hook to disabled list + const newDisabledHooks = [...disabledHooks, hookName]; + const newHooksSettings = { + ...hooksSettings, + disabled: newDisabledHooks, + }; + + // Save updated settings + settings.setValue( + SettingScope.Workspace, + 'hooks' as keyof typeof settings.merged, + newHooksSettings as never, + ); + + debugLogger.info(`✓ Hook "${hookName}" has been disabled.`); + } catch (error) { + debugLogger.error(`Error disabling hook: ${getErrorMessage(error)}`); + } +} + +export const disableCommand: CommandModule = { + command: 'disable ', + describe: 'Disable an active hook', + builder: (yargs) => + yargs.positional('hook-name', { + describe: 'Name of the hook to disable', + type: 'string', + demandOption: true, + }), + handler: async (argv) => { + const args = argv as unknown as DisableArgs; + await handleDisableHook(args.hookName); + process.exit(0); + }, +}; diff --git a/packages/cli/src/commands/hooks/enable.ts b/packages/cli/src/commands/hooks/enable.ts new file mode 100644 index 000000000..863b5b32c --- /dev/null +++ b/packages/cli/src/commands/hooks/enable.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core'; +import { loadSettings, SettingScope } from '../../config/settings.js'; + +const debugLogger = createDebugLogger('HOOKS_ENABLE'); + +interface EnableArgs { + hookName: string; +} + +/** + * Enable a hook by removing it from the disabled list + */ +export async function handleEnableHook(hookName: string): Promise { + const workingDir = process.cwd(); + const settings = loadSettings(workingDir); + + try { + // Get current hooks settings + const mergedSettings = settings.merged as + | Record + | undefined; + const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record< + string, + unknown + >; + const disabledHooks = (hooksSettings['disabled'] || []) as string[]; + + // Check if hook is in disabled list + if (!disabledHooks.includes(hookName)) { + debugLogger.info(`Hook "${hookName}" is not disabled.`); + return; + } + + // Remove hook from disabled list + const newDisabledHooks = disabledHooks.filter((h) => h !== hookName); + const newHooksSettings = { + ...hooksSettings, + disabled: newDisabledHooks, + }; + + // Save updated settings + settings.setValue( + SettingScope.Workspace, + 'hooks' as keyof typeof settings.merged, + newHooksSettings as never, + ); + + debugLogger.info(`✓ Hook "${hookName}" has been enabled.`); + } catch (error) { + debugLogger.error(`Error enabling hook: ${getErrorMessage(error)}`); + } +} + +export const enableCommand: CommandModule = { + command: 'enable ', + describe: 'Enable a disabled hook', + builder: (yargs) => + yargs.positional('hook-name', { + describe: 'Name of the hook to enable', + type: 'string', + demandOption: true, + }), + handler: async (argv) => { + const args = argv as unknown as EnableArgs; + await handleEnableHook(args.hookName); + process.exit(0); + }, +}; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 6d3ee9a53..b4f953c2f 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -548,6 +548,43 @@ describe('loadCliConfig', () => { vi.restoreAllMocks(); }); + it('should reset context file names to QWEN.md and AGENTS.md by default', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = {}; + const setGeminiMdFilenameSpy = vi.spyOn( + ServerConfig, + 'setGeminiMdFilename', + ); + + await loadCliConfig(settings, argv); + + expect(setGeminiMdFilenameSpy).toHaveBeenCalledTimes(1); + expect(setGeminiMdFilenameSpy).toHaveBeenCalledWith([ + ServerConfig.DEFAULT_CONTEXT_FILENAME, + ServerConfig.AGENT_CONTEXT_FILENAME, + ]); + }); + + it('should use configured context file name when settings.context.fileName is set', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { + context: { + fileName: 'CUSTOM_AGENTS.md', + }, + }; + const setGeminiMdFilenameSpy = vi.spyOn( + ServerConfig, + 'setGeminiMdFilename', + ); + + await loadCliConfig(settings, argv); + + expect(setGeminiMdFilenameSpy).toHaveBeenCalledTimes(1); + expect(setGeminiMdFilenameSpy).toHaveBeenCalledWith('CUSTOM_AGENTS.md'); + }); + it('should propagate stream-json formats to config', async () => { process.argv = [ 'node', @@ -567,6 +604,35 @@ describe('loadCliConfig', () => { expect(config.getIncludePartialMessages()).toBe(true); }); + it('should reset context filenames to defaults when context.fileName is not configured', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = {}; + const defaultContextFiles = ['QWEN.md', 'AGENTS.md']; + const getAllSpy = vi + .spyOn(ServerConfig, 'getAllGeminiMdFilenames') + .mockReturnValue(defaultContextFiles); + const setFilenameSpy = vi.spyOn(ServerConfig, 'setGeminiMdFilename'); + + await loadCliConfig(settings, argv); + + expect(getAllSpy).toHaveBeenCalledTimes(1); + expect(setFilenameSpy).toHaveBeenCalledWith(defaultContextFiles); + }); + + it('should use context.fileName from settings when provided', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { context: { fileName: 'CUSTOM_CONTEXT.md' } }; + const getAllSpy = vi.spyOn(ServerConfig, 'getAllGeminiMdFilenames'); + const setFilenameSpy = vi.spyOn(ServerConfig, 'setGeminiMdFilename'); + + await loadCliConfig(settings, argv); + + expect(setFilenameSpy).toHaveBeenCalledWith('CUSTOM_CONTEXT.md'); + expect(getAllSpy).not.toHaveBeenCalled(); + }); + it('should initialize native LSP service when enabled', async () => { process.argv = ['node', 'script.js', '--experimental-lsp']; const argv = await parseArguments(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index c31ffa216..88153fe75 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -11,7 +11,7 @@ import { DEFAULT_QWEN_EMBEDDING_MODEL, FileDiscoveryService, FileEncoding, - getCurrentGeminiMdFilename, + getAllGeminiMdFilenames, loadServerHierarchicalMemory, setGeminiMdFilename as setServerGeminiMdFilename, resolveTelemetrySettings, @@ -33,6 +33,7 @@ import { NativeLspService, } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; +import { hooksCommand } from '../commands/hooks.js'; import type { Settings } from './settings.js'; import { resolveCliGenerationConfig, @@ -124,6 +125,7 @@ export interface CliArgs { acp: boolean | undefined; experimentalAcp: boolean | undefined; experimentalLsp: boolean | undefined; + experimentalHooks: boolean | undefined; extensions: string[] | undefined; listExtensions: boolean | undefined; openaiLogging: boolean | undefined; @@ -137,7 +139,6 @@ export interface CliArgs { googleSearchEngineId: string | undefined; webSearchDefault: string | undefined; screenReader: boolean | undefined; - vlmSwitchMode: string | undefined; inputFormat?: string | undefined; outputFormat: string | undefined; includePartialMessages?: boolean; @@ -338,6 +339,12 @@ export async function parseArguments(): Promise { 'Enable experimental LSP (Language Server Protocol) feature for code intelligence', default: false, }) + .option('experimental-hooks', { + type: 'boolean', + description: + 'Enable experimental hooks feature for lifecycle event customization', + default: false, + }) .option('channel', { type: 'string', choices: ['VSCode', 'ACP', 'SDK', 'CI'], @@ -426,13 +433,6 @@ export async function parseArguments(): Promise { type: 'boolean', description: 'Enable screen reader mode for accessibility.', }) - .option('vlm-switch-mode', { - type: 'string', - choices: ['once', 'session', 'persist'], - description: - 'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.', - default: process.env['VLM_SWITCH_MODE'], - }) .option('input-format', { type: 'string', choices: ['text', 'stream-json'], @@ -569,7 +569,9 @@ export async function parseArguments(): Promise { // Register MCP subcommands .command(mcpCommand) // Register Extension subcommands - .command(extensionsCommand); + .command(extensionsCommand) + // Register Hooks subcommands + .command(hooksCommand); yargsInstance .version(await getCliVersion()) // This will enable the --version flag based on package.json @@ -588,9 +590,11 @@ export async function parseArguments(): Promise { // and not return to main CLI logic if ( result._.length > 0 && - (result._[0] === 'mcp' || result._[0] === 'extensions') + (result._[0] === 'mcp' || + result._[0] === 'extensions' || + result._[0] === 'hooks') ) { - // MCP commands handle their own execution and process exit + // MCP/Extensions/Hooks commands handle their own execution and process exit process.exit(0); } @@ -696,19 +700,26 @@ export async function loadCliConfig( if (settings.context?.fileName) { setServerGeminiMdFilename(settings.context.fileName); } else { - // Reset to default if not provided in settings. - setServerGeminiMdFilename(getCurrentGeminiMdFilename()); + // Reset to default context filenames if not provided in settings. + setServerGeminiMdFilename(getAllGeminiMdFilenames()); } // Automatically load output-language.md if it exists - let outputLanguageFilePath: string | undefined = path.join( + const projectStorage = new Storage(cwd); + const projectOutputLanguagePath = path.join( + projectStorage.getQwenDir(), + 'output-language.md', + ); + const globalOutputLanguagePath = path.join( Storage.getGlobalQwenDir(), 'output-language.md', ); - if (fs.existsSync(outputLanguageFilePath)) { - // output-language.md found - will be added to context files - } else { - outputLanguageFilePath = undefined; + + let outputLanguageFilePath: string | undefined; + if (fs.existsSync(projectOutputLanguagePath)) { + outputLanguageFilePath = projectOutputLanguagePath; + } else if (fs.existsSync(globalOutputLanguagePath)) { + outputLanguageFilePath = globalOutputLanguagePath; } const fileService = new FileDiscoveryService(cwd); @@ -903,9 +914,6 @@ export async function loadCliConfig( ? argv.screenReader : (settings.ui?.accessibility?.screenReader ?? false); - const vlmSwitchMode = - argv.vlmSwitchMode || settings.experimental?.vlmSwitchMode; - let sessionId: string | undefined; let sessionData: ResumedSessionData | undefined; @@ -1002,6 +1010,7 @@ export async function loadCliConfig( modelProvidersConfig, generationConfigSources: resolvedCliConfig.sources, generationConfig: resolvedCliConfig.generationConfig, + warnings: resolvedCliConfig.warnings, cliVersion: await getCliVersion(), webSearch: buildWebSearchConfig(argv, settings, selectedAuthType), summarizeToolOutput: settings.model?.summarizeToolOutput, @@ -1014,9 +1023,8 @@ export async function loadCliConfig( useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep, shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell, skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, - skipLoopDetection: settings.model?.skipLoopDetection ?? false, + skipLoopDetection: settings.model?.skipLoopDetection ?? true, skipStartupContext: settings.model?.skipStartupContext ?? false, - vlmSwitchMode, truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, truncateToolOutputLines: settings.tools?.truncateToolOutputLines, enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, @@ -1025,6 +1033,10 @@ export async function loadCliConfig( output: { format: outputSettingsFormat, }, + hooks: settings.hooks, + hooksConfig: settings.hooksConfig, + enableHooks: + argv.experimentalHooks === true || settings.hooksConfig?.enabled === true, channel: argv.channel, // Precedence: explicit CLI flag > settings file > default(true). // NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 226727c5b..7499a8c68 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -50,6 +50,7 @@ export enum Command { QUIT = 'quit', EXIT = 'exit', SHOW_MORE_LINES = 'showMoreLines', + RETRY_LAST = 'retryLast', // Shell commands REVERSE_SEARCH = 'reverseSearch', @@ -170,6 +171,7 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.QUIT]: [{ key: 'c', ctrl: true }], [Command.EXIT]: [{ key: 'd', ctrl: true }], [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], + [Command.RETRY_LAST]: [{ key: 'y', ctrl: true }], // Shell commands [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], diff --git a/packages/cli/src/config/migration/index.test.ts b/packages/cli/src/config/migration/index.test.ts new file mode 100644 index 000000000..52bae237e --- /dev/null +++ b/packages/cli/src/config/migration/index.test.ts @@ -0,0 +1,383 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + runMigrations, + needsMigration, + ALL_MIGRATIONS, + MigrationScheduler, +} from './index.js'; +import { SETTINGS_VERSION } from '../settings.js'; + +describe('Migration Framework Integration', () => { + describe('runMigrations', () => { + it('should migrate V1 settings to V3', () => { + const v1Settings = { + theme: 'dark', + model: 'gemini', + disableAutoUpdate: true, + disableLoadingPhrases: false, + }; + + const result = runMigrations(v1Settings, 'user'); + + expect(result.finalVersion).toBe(3); + expect(result.executedMigrations).toHaveLength(2); + expect(result.executedMigrations[0]).toEqual({ + fromVersion: 1, + toVersion: 2, + }); + expect(result.executedMigrations[1]).toEqual({ + fromVersion: 2, + toVersion: 3, + }); + + // Check V2 structure was created + const settings = result.settings as Record; + expect(settings['$version']).toBe(3); + expect(settings['ui']).toEqual({ + theme: 'dark', + accessibility: { enableLoadingPhrases: true }, + }); + expect(settings['model']).toEqual({ name: 'gemini' }); + + // Check disableAutoUpdate was inverted to enableAutoUpdate: false + expect( + (settings['general'] as Record)['enableAutoUpdate'], + ).toBe(false); + }); + + it('should migrate V2 settings to V3', () => { + const v2Settings = { + $version: 2, + ui: { theme: 'light' }, + general: { disableAutoUpdate: false }, + }; + + const result = runMigrations(v2Settings, 'user'); + + expect(result.finalVersion).toBe(3); + expect(result.executedMigrations).toHaveLength(1); + expect(result.executedMigrations[0]).toEqual({ + fromVersion: 2, + toVersion: 3, + }); + + const settings = result.settings as Record; + expect(settings['$version']).toBe(3); + expect( + (settings['general'] as Record)['enableAutoUpdate'], + ).toBe(true); + expect( + (settings['general'] as Record)['disableAutoUpdate'], + ).toBeUndefined(); + }); + + it('should not modify V3 settings', () => { + const v3Settings = { + $version: 3, + ui: { theme: 'dark' }, + general: { enableAutoUpdate: true }, + }; + + const result = runMigrations(v3Settings, 'user'); + + expect(result.finalVersion).toBe(3); + expect(result.executedMigrations).toHaveLength(0); + expect(result.settings).toEqual(v3Settings); + }); + + it('should be idempotent', () => { + const v1Settings = { + theme: 'dark', + disableAutoUpdate: true, + }; + + const result1 = runMigrations(v1Settings, 'user'); + const result2 = runMigrations(result1.settings, 'user'); + + expect(result1.executedMigrations).toHaveLength(2); + expect(result2.executedMigrations).toHaveLength(0); + expect(result1.finalVersion).toBe(result2.finalVersion); + }); + }); + + describe('needsMigration', () => { + it('should return true for V1 settings', () => { + const v1Settings = { + theme: 'dark', + model: 'gemini', + }; + + expect(needsMigration(v1Settings)).toBe(true); + }); + + it('should return true for V2 settings with deprecated keys', () => { + const v2Settings = { + $version: 2, + general: { disableAutoUpdate: true }, + }; + + expect(needsMigration(v2Settings)).toBe(true); + }); + + it('should return true for V2 settings without deprecated keys', () => { + const cleanV2Settings = { + $version: 2, + ui: { theme: 'dark' }, + }; + + // V2 settings should be migrated to V3 to update the version number + expect(needsMigration(cleanV2Settings)).toBe(true); + }); + + it('should return false for V3 settings', () => { + const v3Settings = { + $version: 3, + general: { enableAutoUpdate: true }, + }; + + expect(needsMigration(v3Settings)).toBe(false); + }); + + it('should return false for legacy numeric version when no migration can execute', () => { + const legacyButUnknownSettings = { + $version: 1, + customOnlyKey: 'value', + }; + + expect(needsMigration(legacyButUnknownSettings)).toBe(false); + }); + }); + + describe('ALL_MIGRATIONS', () => { + it('should contain all migrations in order', () => { + expect(ALL_MIGRATIONS).toHaveLength(2); + + expect(ALL_MIGRATIONS[0].fromVersion).toBe(1); + expect(ALL_MIGRATIONS[0].toVersion).toBe(2); + + expect(ALL_MIGRATIONS[1].fromVersion).toBe(2); + expect(ALL_MIGRATIONS[1].toVersion).toBe(3); + }); + }); + + describe('MigrationScheduler with all migrations', () => { + it('should execute full migration chain', () => { + const scheduler = new MigrationScheduler([...ALL_MIGRATIONS], 'user'); + + const v1Settings = { + theme: 'dark', + disableAutoUpdate: true, + disableLoadingPhrases: true, + }; + + const result = scheduler.migrate(v1Settings); + + expect(result.executedMigrations).toHaveLength(2); + + const settings = result.settings as Record; + expect(settings['$version']).toBe(3); + expect((settings['ui'] as Record)['theme']).toBe('dark'); + expect( + (settings['general'] as Record)['enableAutoUpdate'], + ).toBe(false); + expect( + ( + (settings['ui'] as Record)[ + 'accessibility' + ] as Record + )['enableLoadingPhrases'], + ).toBe(false); + }); + }); + + describe('needsMigration and runMigrations consistency', () => { + it('needsMigration should return true when runMigrations would execute migrations', () => { + const v1Settings = { + theme: 'dark', + disableAutoUpdate: true, + }; + + // needsMigration should report that migration is needed + expect(needsMigration(v1Settings)).toBe(true); + + // runMigrations should actually execute migrations + const result = runMigrations(v1Settings, 'user'); + expect(result.executedMigrations.length).toBeGreaterThan(0); + }); + + it('needsMigration should return false when runMigrations would execute no migrations', () => { + const v3Settings = { + $version: 3, + general: { enableAutoUpdate: true }, + }; + + // needsMigration should report that no migration is needed + expect(needsMigration(v3Settings)).toBe(false); + + // runMigrations should execute no migrations + const result = runMigrations(v3Settings, 'user'); + expect(result.executedMigrations).toHaveLength(0); + }); + + it('should handle V2 settings without deprecated keys consistently', () => { + const cleanV2Settings = { + $version: 2, + ui: { theme: 'dark' }, + }; + + // needsMigration should report that migration is needed + expect(needsMigration(cleanV2Settings)).toBe(true); + + // runMigrations should execute the V2->V3 migration + const result = runMigrations(cleanV2Settings, 'user'); + expect(result.executedMigrations.length).toBeGreaterThan(0); + expect(result.finalVersion).toBe(3); + }); + }); + + describe('migration chain integrity', () => { + it('should have strictly increasing versions (toVersion > fromVersion)', () => { + for (const migration of ALL_MIGRATIONS) { + expect(migration.toVersion).toBeGreaterThan(migration.fromVersion); + } + }); + + it('should have no gaps in the chain (adjacent versions)', () => { + for (let i = 1; i < ALL_MIGRATIONS.length; i++) { + const prevMigration = ALL_MIGRATIONS[i - 1]; + const currMigration = ALL_MIGRATIONS[i]; + expect(currMigration.fromVersion).toBe(prevMigration.toVersion); + } + }); + + it('should have no duplicate fromVersions', () => { + const fromVersions = ALL_MIGRATIONS.map((m) => m.fromVersion); + const uniqueFromVersions = new Set(fromVersions); + expect(uniqueFromVersions.size).toBe(fromVersions.length); + }); + + it('should have no duplicate toVersions', () => { + const toVersions = ALL_MIGRATIONS.map((m) => m.toVersion); + const uniqueToVersions = new Set(toVersions); + expect(uniqueToVersions.size).toBe(toVersions.length); + }); + + it('should be acyclic (no version appears as fromVersion more than once)', () => { + const fromVersionCounts = new Map(); + for (const migration of ALL_MIGRATIONS) { + const count = fromVersionCounts.get(migration.fromVersion) || 0; + fromVersionCounts.set(migration.fromVersion, count + 1); + } + + for (const count of fromVersionCounts.values()) { + expect(count).toBe(1); + } + }); + + it('should chain from version 1 to SETTINGS_VERSION', () => { + if (ALL_MIGRATIONS.length > 0) { + expect(ALL_MIGRATIONS[0].fromVersion).toBe(1); + const lastMigration = ALL_MIGRATIONS[ALL_MIGRATIONS.length - 1]; + expect(lastMigration.toVersion).toBe(SETTINGS_VERSION); + } + }); + }); + + describe('single source of truth for version constant', () => { + it('should use SETTINGS_VERSION from settings module', () => { + // The last migration's toVersion should match SETTINGS_VERSION + const lastMigration = ALL_MIGRATIONS[ALL_MIGRATIONS.length - 1]; + expect(lastMigration.toVersion).toBe(SETTINGS_VERSION); + }); + + it('needsMigration should use SETTINGS_VERSION for version comparison', () => { + // Create settings with version equal to SETTINGS_VERSION + const currentVersionSettings = { + $version: SETTINGS_VERSION, + general: { enableAutoUpdate: true }, + }; + + // needsMigration should return false for current version + expect(needsMigration(currentVersionSettings)).toBe(false); + + // Create settings with version less than SETTINGS_VERSION + const oldVersionSettings = { + $version: SETTINGS_VERSION - 1, + general: { disableAutoUpdate: true }, + }; + + // needsMigration should return true for old version + expect(needsMigration(oldVersionSettings)).toBe(true); + }); + + it('should have SETTINGS_VERSION defined exactly once in codebase', () => { + // SETTINGS_VERSION is imported from settings.js + // This test verifies the wiring is correct + expect(SETTINGS_VERSION).toBeDefined(); + expect(typeof SETTINGS_VERSION).toBe('number'); + expect(SETTINGS_VERSION).toBeGreaterThan(0); + }); + }); + + describe('invalid version handling', () => { + it('should treat non-numeric version with V1 shape as needing migration', () => { + const settingsWithInvalidVersion = { + $version: 'invalid', + theme: 'dark', + disableAutoUpdate: true, + }; + + // Should detect migration needed based on V1 shape + expect(needsMigration(settingsWithInvalidVersion)).toBe(true); + + // Should run migrations + const result = runMigrations(settingsWithInvalidVersion, 'user'); + expect(result.executedMigrations.length).toBeGreaterThan(0); + expect(result.finalVersion).toBe(SETTINGS_VERSION); + }); + + it('should not migrate non-numeric version with already-migrated shape (normalized by loader)', () => { + const settingsWithInvalidVersionButMigratedShape = { + $version: 'invalid', + general: { enableAutoUpdate: true }, + }; + + // needsMigration returns false because no migration applies to this shape + // The settings loader will handle version normalization separately + expect(needsMigration(settingsWithInvalidVersionButMigratedShape)).toBe( + false, + ); + + // No migrations should execute + const result = runMigrations( + settingsWithInvalidVersionButMigratedShape, + 'user', + ); + expect(result.executedMigrations).toHaveLength(0); + }); + + it('should avoid repeated no-op migration loops', () => { + // Settings that might cause repeated migrations + const v3Settings = { + $version: 3, + general: { enableAutoUpdate: true }, + }; + + // First check + expect(needsMigration(v3Settings)).toBe(false); + const result1 = runMigrations(v3Settings, 'user'); + expect(result1.executedMigrations).toHaveLength(0); + + // Second check should be consistent + expect(needsMigration(result1.settings)).toBe(false); + const result2 = runMigrations(result1.settings, 'user'); + expect(result2.executedMigrations).toHaveLength(0); + }); + }); +}); diff --git a/packages/cli/src/config/migration/index.ts b/packages/cli/src/config/migration/index.ts new file mode 100644 index 000000000..40d176cbe --- /dev/null +++ b/packages/cli/src/config/migration/index.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Export types +export type { SettingsMigration, MigrationResult } from './types.js'; + +// Export scheduler +export { MigrationScheduler } from './scheduler.js'; + +// Export migrations +export { v1ToV2Migration, V1ToV2Migration } from './versions/v1-to-v2.js'; +export { v2ToV3Migration, V2ToV3Migration } from './versions/v2-to-v3.js'; + +// Import settings version from single source of truth +import { SETTINGS_VERSION } from '../settings.js'; + +// Ordered array of all migrations for use with MigrationScheduler +// Each migration handles one version transition (N → N+1) +// Order matters: migrations must be sorted by ascending version +import { v1ToV2Migration } from './versions/v1-to-v2.js'; +import { v2ToV3Migration } from './versions/v2-to-v3.js'; +import { MigrationScheduler } from './scheduler.js'; +import type { MigrationResult } from './types.js'; + +/** + * Ordered array of all settings migrations. + * Use this with MigrationScheduler to run the full migration chain. + * + * @example + * ```typescript + * const scheduler = new MigrationScheduler(ALL_MIGRATIONS); + * const result = scheduler.migrate(settings); + * ``` + */ +export const ALL_MIGRATIONS = [v1ToV2Migration, v2ToV3Migration] as const; + +/** + * Convenience function that runs all migrations on the given settings. + * This is the primary entry point for settings migration. + * + * @param settings - The settings object to migrate + * @param scope - The scope of settings being migrated + * @returns MigrationResult containing the final settings, version, and execution log + * + * @example + * ```typescript + * const result = runMigrations(settings, 'User'); + * if (result.executedMigrations.length > 0) { + * console.log(`Migrated from version ${result.executedMigrations[0].fromVersion} to ${result.finalVersion}`); + * } + * ``` + */ +export function runMigrations( + settings: unknown, + scope: string, +): MigrationResult { + const scheduler = new MigrationScheduler([...ALL_MIGRATIONS], scope); + return scheduler.migrate(settings); +} + +/** + * Checks if the given settings need migration. + * Returns true only if at least one registered migration would be applied. + * + * This function checks: + * 1. If $version field exists and is a number: + * - Returns false if $version >= SETTINGS_VERSION + * - Returns true only when $version < SETTINGS_VERSION AND at least one + * migration can execute for the current settings shape + * 2. If $version field is missing or invalid: + * - Uses fallback logic by checking individual migrations + * + * Note: + * - Legacy numeric versions that have no executable migrations are handled by + * the settings loader via version normalization (bump metadata to current). + * + * @param settings - The settings object to check + * @returns true if migration is needed, false otherwise + */ +export function needsMigration(settings: unknown): boolean { + if (typeof settings !== 'object' || settings === null) { + return false; + } + + const s = settings as Record; + const version = s['$version']; + const hasApplicableMigration = ALL_MIGRATIONS.some((migration) => + migration.shouldMigrate(settings), + ); + + // If $version is a valid number, use version comparison + if (typeof version === 'number') { + if (version >= SETTINGS_VERSION) { + return false; + } + // Guardrail: only report migration-needed if at least one migration can execute. + return hasApplicableMigration; + } + + // If $version exists but is not a number (invalid), or is missing: + // Use fallback logic - check if any migration would be applied + return hasApplicableMigration; +} diff --git a/packages/cli/src/config/migration/scheduler.test.ts b/packages/cli/src/config/migration/scheduler.test.ts new file mode 100644 index 000000000..91e9eff98 --- /dev/null +++ b/packages/cli/src/config/migration/scheduler.test.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { MigrationScheduler } from './scheduler.js'; + +import type { SettingsMigration } from './types.js'; + +describe('MigrationScheduler', () => { + // Mock migration for testing + const createMockMigration = ( + fromVersion: number, + toVersion: number, + shouldMigrateResult: boolean, + ): SettingsMigration => ({ + fromVersion, + toVersion, + shouldMigrate: vi.fn().mockReturnValue(shouldMigrateResult), + migrate: vi.fn((settings) => ({ + settings: { + ...(settings as Record), + $version: toVersion, + }, + warnings: [], + })), + }); + + it('should execute migrations in order when shouldMigrate returns true', () => { + const migration1 = createMockMigration(1, 2, true); + const migration2 = createMockMigration(2, 3, true); + + const scheduler = new MigrationScheduler([migration1, migration2], 'user'); + const result = scheduler.migrate({ $version: 1, someKey: 'value' }); + + expect(migration1.shouldMigrate).toHaveBeenCalledTimes(1); + expect(migration1.migrate).toHaveBeenCalledTimes(1); + expect(migration2.shouldMigrate).toHaveBeenCalledTimes(1); + expect(migration2.migrate).toHaveBeenCalledTimes(1); + + expect(result.executedMigrations).toHaveLength(2); + expect(result.executedMigrations[0]).toEqual({ + fromVersion: 1, + toVersion: 2, + }); + expect(result.executedMigrations[1]).toEqual({ + fromVersion: 2, + toVersion: 3, + }); + expect(result.finalVersion).toBe(3); + }); + + it('should skip migrations when shouldMigrate returns false', () => { + const migration1 = createMockMigration(1, 2, false); + const migration2 = createMockMigration(2, 3, true); + + const scheduler = new MigrationScheduler([migration1, migration2], 'user'); + const result = scheduler.migrate({ $version: 2, someKey: 'value' }); + + expect(migration1.shouldMigrate).toHaveBeenCalledTimes(1); + expect(migration1.migrate).not.toHaveBeenCalled(); + expect(migration2.shouldMigrate).toHaveBeenCalledTimes(1); + expect(migration2.migrate).toHaveBeenCalledTimes(1); + + expect(result.executedMigrations).toHaveLength(1); + expect(result.executedMigrations[0]).toEqual({ + fromVersion: 2, + toVersion: 3, + }); + }); + + it('should be idempotent - running migrations twice produces same result', () => { + // Create a migration that checks the version to determine if migration is needed + const migration1: SettingsMigration = { + fromVersion: 1, + toVersion: 2, + shouldMigrate: vi.fn((settings) => { + const s = settings as Record; + return s['$version'] !== 2; + }), + migrate: vi.fn((settings) => ({ + settings: { + ...(settings as Record), + $version: 2, + }, + warnings: [], + })), + }; + + const scheduler = new MigrationScheduler([migration1], 'user'); + const input = { theme: 'dark' }; + + const result1 = scheduler.migrate(input); + const result2 = scheduler.migrate(result1.settings); + + expect(result1.executedMigrations).toHaveLength(1); + expect(result2.executedMigrations).toHaveLength(0); + expect(result1.finalVersion).toBe(result2.finalVersion); + }); + + it('should pass updated settings to each migration', () => { + const migration1: SettingsMigration = { + fromVersion: 1, + toVersion: 2, + shouldMigrate: vi.fn().mockReturnValue(true), + migrate: vi.fn(() => ({ + settings: { $version: 2, transformed: true }, + warnings: [], + })), + }; + + const migration2: SettingsMigration = { + fromVersion: 2, + toVersion: 3, + shouldMigrate: vi.fn().mockReturnValue(true), + migrate: vi.fn((s) => ({ settings: s, warnings: [] })), + }; + + const scheduler = new MigrationScheduler([migration1, migration2], 'user'); + scheduler.migrate({ $version: 1 }); + + expect(migration2.shouldMigrate).toHaveBeenCalledWith( + expect.objectContaining({ $version: 2, transformed: true }), + ); + }); + + it('should handle empty migrations array', () => { + const scheduler = new MigrationScheduler([], 'user'); + const result = scheduler.migrate({ $version: 1, key: 'value' }); + + expect(result.executedMigrations).toHaveLength(0); + expect(result.finalVersion).toBe(1); + expect(result.settings).toEqual({ $version: 1, key: 'value' }); + }); + + it('should throw error when migration fails', () => { + const migration1: SettingsMigration = { + fromVersion: 1, + toVersion: 2, + shouldMigrate: vi.fn().mockReturnValue(true), + migrate: vi.fn().mockImplementation(() => { + throw new Error('Migration failed'); + }), + }; + + const scheduler = new MigrationScheduler([migration1], 'user'); + + expect(() => scheduler.migrate({ $version: 1 })).toThrow( + 'Migration failed', + ); + }); + + it('should handle settings without version field', () => { + const migration1 = createMockMigration(1, 2, true); + + const scheduler = new MigrationScheduler([migration1], 'user'); + const result = scheduler.migrate({ theme: 'dark' }); + + expect(result.finalVersion).toBe(2); + expect(result.executedMigrations).toHaveLength(1); + }); +}); diff --git a/packages/cli/src/config/migration/scheduler.ts b/packages/cli/src/config/migration/scheduler.ts new file mode 100644 index 000000000..7bbcc43d6 --- /dev/null +++ b/packages/cli/src/config/migration/scheduler.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import type { SettingsMigration, MigrationResult } from './types.js'; + +const debugLogger = createDebugLogger('SETTINGS_MIGRATION'); + +/** + * Formats a SettingScope enum value to a human-readable string. + * - Converts to lowercase + * - Special case: 'SystemDefaults' -> 'system default' + */ +export function formatScope(scope: string): string { + if (scope === 'SystemDefaults') { + return 'system default'; + } + return scope.toLowerCase(); +} + +/** + * Chain scheduler for settings migrations. + * + * The MigrationScheduler orchestrates multiple migrations in sequence, + * delegating version detection to each individual migration via `shouldMigrate`. + * It has no centralized version logic - migrations self-determine applicability. + * + * Key characteristics: + * - Linear chain execution: migrations are applied in registration order + * - Idempotent: already-migrated versions return false from shouldMigrate + * - Adjacent versions only: each migration handles N → N+1 + * - Pure functions: migrations don't modify input objects + */ +export class MigrationScheduler { + /** + * Creates a new MigrationScheduler with the given migrations. + * + * @param migrations - Array of migrations in execution order (typically ascending version) + * @param scope - The scope of settings being migrated + */ + constructor( + private readonly migrations: SettingsMigration[], + private readonly scope: string, + ) {} + + /** + * Executes the migration chain on the given settings. + * + * Iterates through all registered migrations in order. For each migration: + * 1. Calls `shouldMigrate` with the current settings + * 2. If true, calls `migrate` to transform the settings + * 3. Records the execution + * + * The scheduler itself has no version awareness - all version detection + * is delegated to the individual migrations. + * + * @param settings - The settings object to migrate + * @returns MigrationResult containing the final settings, version, and execution log + */ + migrate(settings: unknown): MigrationResult { + debugLogger.debug('MigrationScheduler: Starting migration chain'); + + let current = settings; + const executed: Array<{ fromVersion: number; toVersion: number }> = []; + const allWarnings: string[] = []; + + for (const migration of this.migrations) { + try { + if (migration.shouldMigrate(current)) { + debugLogger.debug( + `MigrationScheduler: Executing migration ${migration.fromVersion} → ${migration.toVersion}`, + ); + + const formattedScope = formatScope(this.scope); + const result = migration.migrate(current, formattedScope); + current = result.settings; + allWarnings.push(...result.warnings); + + executed.push({ + fromVersion: migration.fromVersion, + toVersion: migration.toVersion, + }); + + debugLogger.debug( + `MigrationScheduler: Migration ${migration.fromVersion} → ${migration.toVersion} completed successfully`, + ); + } + } catch (error) { + debugLogger.error( + `MigrationScheduler: Migration ${migration.fromVersion} → ${migration.toVersion} failed:`, + error, + ); + throw error; + } + } + + // Determine final version from the settings object + const finalVersion = + ((current as Record)['$version'] as number) ?? 1; + + debugLogger.debug( + `MigrationScheduler: Migration chain complete. Final version: ${finalVersion}, Executed: ${executed.length} migrations`, + ); + + return { + settings: current, + finalVersion, + executedMigrations: executed, + warnings: allWarnings, + }; + } +} diff --git a/packages/cli/src/config/migration/types.ts b/packages/cli/src/config/migration/types.ts new file mode 100644 index 000000000..ca1e23aaf --- /dev/null +++ b/packages/cli/src/config/migration/types.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Interface that all settings migrations must implement. + * Each migration handles a single version transition (N → N+1). + */ +export interface SettingsMigration { + /** Source version number */ + readonly fromVersion: number; + + /** Target version number */ + readonly toVersion: number; + + /** + * Determines whether this migration should be applied to the given settings. + * The migration inspects the settings object to detect its current version + * and returns true if this migration is applicable. + * + * @param settings - The current settings object + * @returns true if this migration should be applied, false otherwise + */ + shouldMigrate(settings: unknown): boolean; + + /** + * Executes the migration transformation. + * This should be a pure function that does not modify the input object. + * + * @param settings - The current settings object of version N + * @param scope - The scope of settings being migrated + * @returns The migrated settings object of version N+1 with optional warnings + * @throws Error if the migration fails + */ + migrate( + settings: unknown, + scope: string, + ): { settings: unknown; warnings: string[] }; +} + +/** + * Result of a migration execution by MigrationScheduler. + */ +export interface MigrationResult { + /** The final settings object after all applicable migrations */ + settings: unknown; + + /** The final version number after migrations */ + finalVersion: number; + + /** List of migrations that were executed */ + executedMigrations: Array<{ fromVersion: number; toVersion: number }>; + + /** List of warning messages generated during migration */ + warnings: string[]; +} diff --git a/packages/cli/src/config/migration/versions/v1-to-v2-shared.ts b/packages/cli/src/config/migration/versions/v1-to-v2-shared.ts new file mode 100644 index 000000000..c87fa4480 --- /dev/null +++ b/packages/cli/src/config/migration/versions/v1-to-v2-shared.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Structural mapping table for V1 -> V2. + * + * Used by: + * - v1->v2 migration execution + * - warnings for residual legacy keys in latest-version settings files + */ +export const V1_TO_V2_MIGRATION_MAP: Record = { + accessibility: 'ui.accessibility', + allowedTools: 'tools.allowed', + allowMCPServers: 'mcp.allowed', + autoAccept: 'tools.autoAccept', + autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory', + bugCommand: 'advanced.bugCommand', + chatCompression: 'model.chatCompression', + checkpointing: 'general.checkpointing', + coreTools: 'tools.core', + contextFileName: 'context.fileName', + customThemes: 'ui.customThemes', + customWittyPhrases: 'ui.customWittyPhrases', + debugKeystrokeLogging: 'general.debugKeystrokeLogging', + dnsResolutionOrder: 'advanced.dnsResolutionOrder', + enforcedAuthType: 'security.auth.enforcedType', + excludeTools: 'tools.exclude', + excludeMCPServers: 'mcp.excluded', + excludedProjectEnvVars: 'advanced.excludedEnvVars', + extensions: 'extensions', + fileFiltering: 'context.fileFiltering', + folderTrustFeature: 'security.folderTrust.featureEnabled', + folderTrust: 'security.folderTrust.enabled', + hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge', + hideWindowTitle: 'ui.hideWindowTitle', + showStatusInTitle: 'ui.showStatusInTitle', + hideTips: 'ui.hideTips', + showLineNumbers: 'ui.showLineNumbers', + showCitations: 'ui.showCitations', + ideMode: 'ide.enabled', + includeDirectories: 'context.includeDirectories', + loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories', + maxSessionTurns: 'model.maxSessionTurns', + mcpServers: 'mcpServers', + mcpServerCommand: 'mcp.serverCommand', + memoryImportFormat: 'context.importFormat', + model: 'model.name', + preferredEditor: 'general.preferredEditor', + sandbox: 'tools.sandbox', + selectedAuthType: 'security.auth.selectedType', + shouldUseNodePtyShell: 'tools.shell.enableInteractiveShell', + shellPager: 'tools.shell.pager', + shellShowColor: 'tools.shell.showColor', + skipNextSpeakerCheck: 'model.skipNextSpeakerCheck', + summarizeToolOutput: 'model.summarizeToolOutput', + telemetry: 'telemetry', + theme: 'ui.theme', + toolDiscoveryCommand: 'tools.discoveryCommand', + toolCallCommand: 'tools.callCommand', + usageStatisticsEnabled: 'privacy.usageStatisticsEnabled', + useExternalAuth: 'security.auth.useExternal', + useRipgrep: 'tools.useRipgrep', + vimMode: 'general.vimMode', + enableWelcomeBack: 'ui.enableWelcomeBack', + approvalMode: 'tools.approvalMode', + sessionTokenLimit: 'model.sessionTokenLimit', + contentGenerator: 'model.generationConfig', + skipLoopDetection: 'model.skipLoopDetection', + skipStartupContext: 'model.skipStartupContext', + enableOpenAILogging: 'model.enableOpenAILogging', + tavilyApiKey: 'advanced.tavilyApiKey', +}; + +/** + * Top-level keys that are V2/V3 containers. + * If one of these keys already has object value, treat it as latest-format data. + */ +export const V2_CONTAINER_KEYS = new Set([ + 'ui', + 'tools', + 'mcp', + 'advanced', + 'model', + 'general', + 'context', + 'security', + 'ide', + 'privacy', + 'telemetry', + 'extensions', +]); + +/** + * Legacy disable* keys that remain in disable* form for V2. + */ +export const V1_TO_V2_PRESERVE_DISABLE_MAP: Record = { + disableAutoUpdate: 'general.disableAutoUpdate', + disableUpdateNag: 'general.disableUpdateNag', + disableLoadingPhrases: 'ui.accessibility.disableLoadingPhrases', + disableFuzzySearch: 'context.fileFiltering.disableFuzzySearch', + disableCacheControl: 'model.generationConfig.disableCacheControl', +}; + +export const CONSOLIDATED_DISABLE_KEYS = new Set([ + 'disableAutoUpdate', + 'disableUpdateNag', +]); + +/** + * Keys that indicate V1-like top-level structure when holding primitive values. + */ +export const V1_INDICATOR_KEYS = [ + // From V1_TO_V2_MIGRATION_MAP - keys that map to different paths in V2 + 'theme', + 'model', + 'autoAccept', + 'hideTips', + 'vimMode', + 'checkpointing', + 'accessibility', + 'allowedTools', + 'allowMCPServers', + 'autoConfigureMaxOldSpaceSize', + 'bugCommand', + 'chatCompression', + 'coreTools', + 'contextFileName', + 'customThemes', + 'customWittyPhrases', + 'debugKeystrokeLogging', + 'dnsResolutionOrder', + 'enforcedAuthType', + 'excludeTools', + 'excludeMCPServers', + 'excludedProjectEnvVars', + 'fileFiltering', + 'folderTrustFeature', + 'folderTrust', + 'hasSeenIdeIntegrationNudge', + 'hideWindowTitle', + 'showStatusInTitle', + 'showLineNumbers', + 'showCitations', + 'ideMode', + 'includeDirectories', + 'loadMemoryFromIncludeDirectories', + 'maxSessionTurns', + 'mcpServerCommand', + 'memoryImportFormat', + 'preferredEditor', + 'sandbox', + 'selectedAuthType', + 'shouldUseNodePtyShell', + 'shellPager', + 'shellShowColor', + 'skipNextSpeakerCheck', + 'summarizeToolOutput', + 'toolDiscoveryCommand', + 'toolCallCommand', + 'usageStatisticsEnabled', + 'useExternalAuth', + 'useRipgrep', + 'enableWelcomeBack', + 'approvalMode', + 'sessionTokenLimit', + 'contentGenerator', + 'skipLoopDetection', + 'skipStartupContext', + 'enableOpenAILogging', + 'tavilyApiKey', + // From V1_TO_V2_PRESERVE_DISABLE_MAP - disable* keys that get nested in V2 + 'disableAutoUpdate', + 'disableUpdateNag', + 'disableLoadingPhrases', + 'disableFuzzySearch', + 'disableCacheControl', +]; diff --git a/packages/cli/src/config/migration/versions/v1-to-v2.test.ts b/packages/cli/src/config/migration/versions/v1-to-v2.test.ts new file mode 100644 index 000000000..cbe655c54 --- /dev/null +++ b/packages/cli/src/config/migration/versions/v1-to-v2.test.ts @@ -0,0 +1,277 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { V1ToV2Migration } from './v1-to-v2.js'; + +describe('V1ToV2Migration', () => { + const migration = new V1ToV2Migration(); + + describe('shouldMigrate', () => { + it('should return true for V1 settings without version and with V1 keys', () => { + const v1Settings = { + theme: 'dark', + model: 'gemini', + }; + + expect(migration.shouldMigrate(v1Settings)).toBe(true); + }); + + it('should return true for V1 settings with disable* keys', () => { + const v1Settings = { + disableAutoUpdate: true, + disableLoadingPhrases: false, + }; + + expect(migration.shouldMigrate(v1Settings)).toBe(true); + }); + + it('should return false for settings with $version field', () => { + const v2Settings = { + $version: 2, + ui: { theme: 'dark' }, + }; + + expect(migration.shouldMigrate(v2Settings)).toBe(false); + }); + + it('should return false for V3 settings', () => { + const v3Settings = { + $version: 3, + general: { enableAutoUpdate: true }, + }; + + expect(migration.shouldMigrate(v3Settings)).toBe(false); + }); + + it('should return false for settings without V1 indicator keys', () => { + const unknownSettings = { + customKey: 'value', + anotherKey: 123, + }; + + expect(migration.shouldMigrate(unknownSettings)).toBe(false); + }); + + it('should return false for null input', () => { + expect(migration.shouldMigrate(null)).toBe(false); + }); + + it('should return false for non-object input', () => { + expect(migration.shouldMigrate('string')).toBe(false); + expect(migration.shouldMigrate(123)).toBe(false); + }); + }); + + describe('migrate', () => { + it('should migrate flat V1 keys to nested V2 structure', () => { + const v1Settings = { + theme: 'dark', + model: 'gemini', + autoAccept: true, + hideTips: false, + }; + + const { settings: result } = migration.migrate(v1Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(2); + expect(result['ui']).toEqual({ theme: 'dark', hideTips: false }); + expect(result['model']).toEqual({ name: 'gemini' }); + expect(result['tools']).toEqual({ autoAccept: true }); + }); + + it('should migrate disable* keys to nested V2 paths without inversion', () => { + const v1Settings = { + theme: 'light', + disableAutoUpdate: true, + disableLoadingPhrases: false, + }; + + const { settings: result } = migration.migrate(v1Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(2); + expect(result['general']).toEqual({ disableAutoUpdate: true }); + expect(result['ui']).toEqual({ + theme: 'light', + accessibility: { disableLoadingPhrases: false }, + }); + }); + + it('should normalize consolidated disable* non-boolean values to false', () => { + const v1Settings = { + theme: 'dark', + disableAutoUpdate: 'false', + disableUpdateNag: null, + }; + + const { settings: result } = migration.migrate(v1Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(2); + expect(result['general']).toEqual({ + disableAutoUpdate: false, + disableUpdateNag: false, + }); + }); + + it('should drop non-boolean non-consolidated disable* values', () => { + const v1Settings = { + theme: 'dark', + disableLoadingPhrases: 'TRUE', + disableFuzzySearch: 1, + }; + + const { settings: result } = migration.migrate(v1Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(2); + expect( + (result['ui'] as Record)?.['accessibility'], + ).toBeUndefined(); + expect( + ( + (result['context'] as Record)?.[ + 'fileFiltering' + ] as Record + )?.['disableFuzzySearch'], + ).toBeUndefined(); + }); + + it('should preserve mcpServers at top level', () => { + const v1Settings = { + theme: 'dark', + mcpServers: { + myServer: { command: 'node', args: ['server.js'] }, + }, + }; + + const { settings: result } = migration.migrate(v1Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(2); + expect(result['mcpServers']).toEqual({ + myServer: { command: 'node', args: ['server.js'] }, + }); + }); + + it('should preserve unrecognized keys', () => { + const v1Settings = { + theme: 'dark', + myCustomSetting: 'value', + anotherCustom: 123, + }; + + const { settings: result } = migration.migrate(v1Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(2); + expect(result['myCustomSetting']).toBe('value'); + expect(result['anotherCustom']).toBe(123); + }); + + it('should preserve non-object parent path values on collision', () => { + const v1Settings = { + theme: 'dark', + disableAutoUpdate: true, + ui: 'legacy-ui-string', + general: 'legacy-general-string', + }; + + const { settings: result } = migration.migrate(v1Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(2); + expect(result['ui']).toBe('legacy-ui-string'); + expect(result['general']).toBe('legacy-general-string'); + }); + + it('should not modify the input object', () => { + const v1Settings = { + theme: 'dark', + model: 'gemini', + }; + + const { settings: result } = migration.migrate(v1Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(v1Settings).toEqual({ theme: 'dark', model: 'gemini' }); + expect(result).not.toBe(v1Settings); + }); + + it('should throw error for non-object input', () => { + expect(() => migration.migrate(null, 'user')).toThrow( + 'Settings must be an object', + ); + expect(() => migration.migrate('string', 'user')).toThrow( + 'Settings must be an object', + ); + }); + + it('should handle empty V1 settings', () => { + const v1Settings = { + theme: 'dark', + }; + + const { settings: result } = migration.migrate(v1Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(2); + expect(result['ui']).toEqual({ theme: 'dark' }); + }); + + it('should correctly handle all V1 indicator keys', () => { + const v1Settings = { + theme: 'dark', + model: 'gemini', + autoAccept: true, + hideTips: false, + vimMode: true, + checkpointing: false, + telemetry: {}, + accessibility: {}, + extensions: [], + mcpServers: {}, + }; + + const { settings: result } = migration.migrate(v1Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(2); + }); + }); + + describe('version properties', () => { + it('should have correct fromVersion', () => { + expect(migration.fromVersion).toBe(1); + }); + + it('should have correct toVersion', () => { + expect(migration.toVersion).toBe(2); + }); + }); +}); diff --git a/packages/cli/src/config/migration/versions/v1-to-v2.ts b/packages/cli/src/config/migration/versions/v1-to-v2.ts new file mode 100644 index 000000000..4dceffe44 --- /dev/null +++ b/packages/cli/src/config/migration/versions/v1-to-v2.ts @@ -0,0 +1,267 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SettingsMigration } from '../types.js'; +import { + CONSOLIDATED_DISABLE_KEYS, + V1_INDICATOR_KEYS, + V1_TO_V2_MIGRATION_MAP, + V1_TO_V2_PRESERVE_DISABLE_MAP, + V2_CONTAINER_KEYS, +} from './v1-to-v2-shared.js'; +import { setNestedPropertySafe } from '../../../utils/settingsUtils.js'; + +/** + * Heuristic indicators for deciding whether an object is "V1-like". + * + * Detection strategy: + * - A file is considered migratable as V1 when: + * 1) It is not explicitly versioned as V2+ (`$version` is missing or invalid), and + * 2) At least one indicator key appears in a legacy-compatible top-level shape. + * - Indicator list intentionally excludes keys that are valid top-level entries in + * both old and new structures to reduce false positives. + * + * Shape rule: + * - Object values for indicator keys are treated as already-nested V2-like content + * and do not alone trigger migration. + * - Primitive/array/null values on indicator keys are treated as legacy V1 signals. + */ + +/** + * V1 -> V2 migration (structural normalization stage). + * + * Migration contract: + * - Input: settings in legacy V1-like shape (mostly flat, may contain mixed partial V2). + * - Output: V2-compatible nested structure with `$version: 2`. + * - No semantic inversion of disable* naming in this stage. + * + * Data-preservation strategy: + * - Prefer transforming known keys into canonical V2 locations. + * - Preserve unrecognized keys verbatim. + * - Preserve parent-path scalar values when nested writes would collide with them. + * - Preserve/merge existing partial V2 objects where safe. + * + * This class intentionally optimizes for backward compatibility and non-destructive + * behavior over aggressive normalization. + */ +export class V1ToV2Migration implements SettingsMigration { + readonly fromVersion = 1; + readonly toVersion = 2; + + /** + * Determines whether this migration should execute. + * + * Decision strategy: + * - Hard-stop when `$version` is a number >= 2 (already V2+). + * - Otherwise, scan indicator keys and trigger only when at least one indicator is + * still in legacy top-level shape (primitive/array/null). + * + * Mixed-shape tolerance: + * - Files that are partially migrated are supported; V2-like object-valued indicators + * are ignored while legacy-shaped indicators can still trigger migration. + */ + shouldMigrate(settings: unknown): boolean { + if (typeof settings !== 'object' || settings === null) { + return false; + } + + const s = settings as Record; + + // If $version exists and is a number >= 2, it's not V1 + const version = s['$version']; + if (typeof version === 'number' && version >= 2) { + return false; + } + + // Check for V1 indicator keys with primitive values + // A setting is considered V1 if ANY indicator key has a primitive value + // (string, number, boolean, null, or array) at the top level. + // Keys with object values are skipped as they may already be in V2 format. + return V1_INDICATOR_KEYS.some((key) => { + if (!(key in s)) { + return false; + } + const value = s[key]; + // Skip keys with object values - they may already be in V2 nested format + // But don't let them block migration of other keys + if ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { + // This key appears to be in V2 format, skip it but continue + // checking other keys + return false; + } + // Found a key with primitive value - this is V1 format + return true; + }); + } + + /** + * Performs non-destructive V1 -> V2 transformation. + * + * Detailed strategy: + * 1) Relocate known V1 keys using `V1_TO_V2_MIGRATION_MAP`. + * - If a source value is already an object and maps to a child path of itself + * (partial V2 shape), merge child properties into target path. + * 2) Relocate disable* keys into V2 disable* locations. + * - Consolidated keys (`disableAutoUpdate`, `disableUpdateNag`): normalize to + * boolean with stable-compatible presence semantics (`value === true`). + * - Other disable* keys: migrate only boolean values. + * 3) Preserve `mcpServers` top-level placement. + * 4) Carry over remaining keys: + * - If a key is parent of migrated nested paths, merge unprocessed object children. + * - If parent value is non-object, preserve that scalar/array/null as-is. + * - Otherwise copy untouched key/value. + * 5) Stamp `$version = 2`. + * + * The method is pure with respect to input mutation. + */ + migrate( + settings: unknown, + _scope: string, + ): { settings: unknown; warnings: string[] } { + if (typeof settings !== 'object' || settings === null) { + throw new Error('Settings must be an object'); + } + + const source = settings as Record; + const result: Record = {}; + const processedKeys = new Set(); + const warnings: string[] = []; + + // Step 1: Map known V1 keys to V2 nested paths + for (const [v1Key, v2Path] of Object.entries(V1_TO_V2_MIGRATION_MAP)) { + if (v1Key in source) { + const value = source[v1Key]; + + // Safety check: If this key is a V2 container (like 'model') and it's + // already an object, it's likely already in V2 format. Skip migration + // to prevent double-nesting (e.g., model.name.name). + if ( + V2_CONTAINER_KEYS.has(v1Key) && + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { + // This is already a V2 container, carry it over as-is + result[v1Key] = value; + processedKeys.add(v1Key); + continue; + } + + // If value is already an object and the path matches the key, + // it might be a partial V2 structure. Merge its contents. + if ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + v2Path.startsWith(v1Key + '.') + ) { + // Merge nested properties from this partial V2 structure + for (const [nestedKey, nestedValue] of Object.entries(value)) { + setNestedPropertySafe( + result, + `${v2Path}.${nestedKey}`, + nestedValue, + ); + } + } else { + setNestedPropertySafe(result, v2Path, value); + } + processedKeys.add(v1Key); + } + } + + // Step 2: Map V1 disable* keys to V2 nested disable* paths + for (const [v1Key, v2Path] of Object.entries( + V1_TO_V2_PRESERVE_DISABLE_MAP, + )) { + if (v1Key in source) { + const value = source[v1Key]; + if (CONSOLIDATED_DISABLE_KEYS.has(v1Key)) { + // Preserve stable behavior: consolidated keys use presence semantics. + // Only literal true remains true; all other present values become false. + setNestedPropertySafe(result, v2Path, value === true); + } else if (typeof value === 'boolean') { + // Non-consolidated disable* keys only migrate when explicitly boolean. + setNestedPropertySafe(result, v2Path, value); + } + processedKeys.add(v1Key); + } + } + + // Step 3: Preserve mcpServers at the top level + if ('mcpServers' in source) { + result['mcpServers'] = source['mcpServers']; + processedKeys.add('mcpServers'); + } + + // Step 4: Carry over any unrecognized keys (including unknown nested objects) + // Important: Skip keys that are parent paths of already-migrated properties + // to avoid overwriting merged structures (e.g., 'ui' should not overwrite 'ui.theme') + for (const key of Object.keys(source)) { + if (!processedKeys.has(key)) { + // Check if this key is a parent of any already-migrated path + const isParentOfMigratedPath = Array.from(processedKeys).some( + (processedKey) => { + // Get the v2 path for this processed key + const v2Path = + V1_TO_V2_MIGRATION_MAP[processedKey] || + V1_TO_V2_PRESERVE_DISABLE_MAP[processedKey]; + if (!v2Path) return false; + // Check if the v2 path starts with this key + '.' + return v2Path.startsWith(key + '.'); + }, + ); + + if (isParentOfMigratedPath) { + // This key is a parent of an already-migrated path + // Merge its unprocessed children instead of overwriting + const existingValue = source[key]; + if ( + typeof existingValue === 'object' && + existingValue !== null && + !Array.isArray(existingValue) + ) { + for (const [nestedKey, nestedValue] of Object.entries( + existingValue, + )) { + // Only merge if this nested key wasn't already processed + const fullNestedPath = `${key}.${nestedKey}`; + const wasProcessed = Array.from(processedKeys).some( + (processedKey) => { + const v2Path = + V1_TO_V2_MIGRATION_MAP[processedKey] || + V1_TO_V2_PRESERVE_DISABLE_MAP[processedKey]; + return v2Path === fullNestedPath; + }, + ); + if (!wasProcessed) { + setNestedPropertySafe(result, fullNestedPath, nestedValue); + } + } + } else { + // Preserve non-object parent values to match legacy overwrite semantics. + result[key] = source[key]; + } + } else { + // Not a parent path, safe to copy as-is + result[key] = source[key]; + } + } + } + + // Step 5: Set version to 2 + result['$version'] = 2; + + return { settings: result, warnings }; + } +} + +/** Singleton instance of V1→V2 migration */ +export const v1ToV2Migration = new V1ToV2Migration(); diff --git a/packages/cli/src/config/migration/versions/v2-to-v3.test.ts b/packages/cli/src/config/migration/versions/v2-to-v3.test.ts new file mode 100644 index 000000000..a1ba9b46d --- /dev/null +++ b/packages/cli/src/config/migration/versions/v2-to-v3.test.ts @@ -0,0 +1,598 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { V2ToV3Migration } from './v2-to-v3.js'; + +describe('V2ToV3Migration', () => { + const migration = new V2ToV3Migration(); + + describe('shouldMigrate', () => { + it('should return true for V2 settings with deprecated disable* keys', () => { + const v2Settings = { + $version: 2, + general: { disableAutoUpdate: true }, + }; + + expect(migration.shouldMigrate(v2Settings)).toBe(true); + }); + + it('should return true for V2 settings with ui.accessibility.disableLoadingPhrases', () => { + const v2Settings = { + $version: 2, + ui: { accessibility: { disableLoadingPhrases: false } }, + }; + + expect(migration.shouldMigrate(v2Settings)).toBe(true); + }); + + it('should return false for V3 settings', () => { + const v3Settings = { + $version: 3, + general: { enableAutoUpdate: true }, + }; + + expect(migration.shouldMigrate(v3Settings)).toBe(false); + }); + + it('should return false for V1 settings without version', () => { + const v1Settings = { + theme: 'dark', + disableAutoUpdate: true, + }; + + expect(migration.shouldMigrate(v1Settings)).toBe(false); + }); + + it('should return true for V2 settings without deprecated keys', () => { + const cleanV2Settings = { + $version: 2, + ui: { theme: 'dark' }, + general: { enableAutoUpdate: true }, + }; + + // V2 settings should always be migrated to V3 to update the version number + expect(migration.shouldMigrate(cleanV2Settings)).toBe(true); + }); + + it('should return false for null input', () => { + expect(migration.shouldMigrate(null)).toBe(false); + }); + + it('should return false for non-object input', () => { + expect(migration.shouldMigrate('string')).toBe(false); + expect(migration.shouldMigrate(123)).toBe(false); + }); + }); + + describe('migrate', () => { + it('should migrate disableAutoUpdate to enableAutoUpdate with inverted value', () => { + const v2Settings = { + $version: 2, + general: { disableAutoUpdate: true }, + }; + + const { settings: result } = migration.migrate(v2Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['general'] as Record)['enableAutoUpdate'], + ).toBe(false); + expect( + (result['general'] as Record)['disableAutoUpdate'], + ).toBeUndefined(); + }); + + it('should migrate disableLoadingPhrases to enableLoadingPhrases', () => { + const v2Settings = { + $version: 2, + ui: { accessibility: { disableLoadingPhrases: true } }, + }; + + const { settings: result } = migration.migrate(v2Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['ui'] as Record)['accessibility'], + ).toEqual({ + enableLoadingPhrases: false, + }); + }); + + it('should migrate disableFuzzySearch to enableFuzzySearch', () => { + const v2Settings = { + $version: 2, + context: { fileFiltering: { disableFuzzySearch: false } }, + }; + + const { settings: result } = migration.migrate(v2Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['context'] as Record)['fileFiltering'], + ).toEqual({ + enableFuzzySearch: true, + }); + }); + + it('should migrate disableCacheControl to enableCacheControl', () => { + const v2Settings = { + $version: 2, + model: { generationConfig: { disableCacheControl: true } }, + }; + + const { settings: result } = migration.migrate(v2Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['model'] as Record)['generationConfig'], + ).toEqual({ + enableCacheControl: false, + }); + }); + + it('should handle consolidated disableAutoUpdate and disableUpdateNag', () => { + const v2Settings = { + $version: 2, + general: { + disableAutoUpdate: true, + disableUpdateNag: false, + }, + }; + + const { settings: result } = migration.migrate(v2Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(3); + // If ANY disable* is true, enable should be false + expect( + (result['general'] as Record)['enableAutoUpdate'], + ).toBe(false); + expect( + (result['general'] as Record)['disableAutoUpdate'], + ).toBeUndefined(); + expect( + (result['general'] as Record)['disableUpdateNag'], + ).toBeUndefined(); + }); + + it('should set enableAutoUpdate to true when both disable* are false', () => { + const v2Settings = { + $version: 2, + general: { + disableAutoUpdate: false, + disableUpdateNag: false, + }, + }; + + const { settings: result } = migration.migrate(v2Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['general'] as Record)['enableAutoUpdate'], + ).toBe(true); + }); + + it('should preserve other settings during migration', () => { + const v2Settings = { + $version: 2, + ui: { + theme: 'dark', + accessibility: { disableLoadingPhrases: true }, + }, + model: { + name: 'gemini', + }, + }; + + const { settings: result } = migration.migrate(v2Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(3); + expect((result['ui'] as Record)['theme']).toBe('dark'); + expect((result['model'] as Record)['name']).toBe( + 'gemini', + ); + expect( + (result['ui'] as Record)['accessibility'], + ).toEqual({ + enableLoadingPhrases: false, + }); + }); + + it('should not modify the input object', () => { + const v2Settings = { + $version: 2, + general: { disableAutoUpdate: true }, + }; + + const result = migration.migrate(v2Settings, 'user'); + + expect(v2Settings.general).toEqual({ disableAutoUpdate: true }); + expect(result).not.toBe(v2Settings); + }); + + it('should throw error for non-object input', () => { + expect(() => migration.migrate(null, 'user')).toThrow( + 'Settings must be an object', + ); + expect(() => migration.migrate('string', 'user')).toThrow( + 'Settings must be an object', + ); + }); + + it('should handle multiple deprecated keys in one migration', () => { + const v2Settings = { + $version: 2, + general: { disableAutoUpdate: false }, + ui: { accessibility: { disableLoadingPhrases: false } }, + context: { fileFiltering: { disableFuzzySearch: false } }, + }; + + const { settings: result } = migration.migrate(v2Settings, 'user') as { + settings: Record; + warnings: unknown[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['general'] as Record)['enableAutoUpdate'], + ).toBe(true); + expect( + (result['ui'] as Record)['accessibility'], + ).toEqual({ + enableLoadingPhrases: true, + }); + expect( + (result['context'] as Record)['fileFiltering'], + ).toEqual({ + enableFuzzySearch: true, + }); + }); + + it('should coerce string "true" and remove deprecated key', () => { + const v2Settings = { + $version: 2, + general: { disableAutoUpdate: 'true' }, + }; + + const { settings: result, warnings } = migration.migrate( + v2Settings, + 'user', + ) as { + settings: Record; + warnings: string[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['general'] as Record)['disableAutoUpdate'], + ).toBeUndefined(); + expect( + (result['general'] as Record)['enableAutoUpdate'], + ).toBe(false); + expect(warnings).toHaveLength(0); + }); + + it('should coerce string "false" and remove deprecated key', () => { + const v2Settings = { + $version: 2, + general: { disableAutoUpdate: 'false' }, + }; + + const { settings: result, warnings } = migration.migrate( + v2Settings, + 'user', + ) as { + settings: Record; + warnings: string[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['general'] as Record)['disableAutoUpdate'], + ).toBeUndefined(); + expect( + (result['general'] as Record)['enableAutoUpdate'], + ).toBe(true); + expect(warnings).toHaveLength(0); + }); + + it('should coerce case-insensitive strings for consolidated keys', () => { + const v2Settings = { + $version: 2, + general: { + disableAutoUpdate: 'TRUE', + disableUpdateNag: 'FALSE', + }, + }; + + const { settings: result, warnings } = migration.migrate( + v2Settings, + 'user', + ) as { + settings: Record; + warnings: string[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['general'] as Record)['disableAutoUpdate'], + ).toBeUndefined(); + expect( + (result['general'] as Record)['disableUpdateNag'], + ).toBeUndefined(); + expect( + (result['general'] as Record)['enableAutoUpdate'], + ).toBe(false); + expect(warnings).toHaveLength(0); + }); + + it('should remove number value and emit warning', () => { + const v2Settings = { + $version: 2, + general: { disableAutoUpdate: 123 }, + }; + + const { settings: result, warnings } = migration.migrate( + v2Settings, + 'user', + ) as { + settings: Record; + warnings: string[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['general'] as Record)['disableAutoUpdate'], + ).toBeUndefined(); + expect( + (result['general'] as Record)['enableAutoUpdate'], + ).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('general.disableAutoUpdate'); + }); + + it('should remove invalid string value and emit warning', () => { + const v2Settings = { + $version: 2, + general: { disableAutoUpdate: 'invalid-string' }, + }; + + const { settings: result, warnings } = migration.migrate( + v2Settings, + 'user', + ) as { + settings: Record; + warnings: string[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['general'] as Record)['disableAutoUpdate'], + ).toBeUndefined(); + expect( + (result['general'] as Record)['enableAutoUpdate'], + ).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('general.disableAutoUpdate'); + }); + + it('should coerce disableCacheControl string "true"', () => { + const v2Settings = { + $version: 2, + model: { generationConfig: { disableCacheControl: 'true' } }, + }; + + const { settings: result, warnings } = migration.migrate( + v2Settings, + 'user', + ) as { + settings: Record; + warnings: string[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['model'] as Record)['generationConfig'], + ).toEqual({ + enableCacheControl: false, + }); + expect(warnings).toHaveLength(0); + }); + + it('should coerce disableCacheControl string "false"', () => { + const v2Settings = { + $version: 2, + model: { generationConfig: { disableCacheControl: 'false' } }, + }; + + const { settings: result, warnings } = migration.migrate( + v2Settings, + 'user', + ) as { + settings: Record; + warnings: string[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['model'] as Record)['generationConfig'], + ).toEqual({ + enableCacheControl: true, + }); + expect(warnings).toHaveLength(0); + }); + + it('should remove disableCacheControl number value and emit warning', () => { + const v2Settings = { + $version: 2, + model: { generationConfig: { disableCacheControl: 456 } }, + }; + + const { settings: result, warnings } = migration.migrate( + v2Settings, + 'user', + ) as { + settings: Record; + warnings: string[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['model'] as Record)['generationConfig'], + ).toEqual({}); + expect( + ( + (result['model'] as Record)[ + 'generationConfig' + ] as Record + )['enableCacheControl'], + ).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain( + 'model.generationConfig.disableCacheControl', + ); + }); + + it('should handle mixed valid and invalid disableAutoUpdate and disableUpdateNag', () => { + const v2Settings = { + $version: 2, + general: { + disableAutoUpdate: true, + disableUpdateNag: 'invalid', + }, + }; + + const { settings: result, warnings } = migration.migrate( + v2Settings, + 'user', + ) as { + settings: Record; + warnings: string[]; + }; + + expect(result['$version']).toBe(3); + // Only valid values should contribute to the consolidated result + // Since disableAutoUpdate is true, enableAutoUpdate should be false + expect( + (result['general'] as Record)['enableAutoUpdate'], + ).toBe(false); + expect( + (result['general'] as Record)['disableAutoUpdate'], + ).toBeUndefined(); + expect( + (result['general'] as Record)['disableUpdateNag'], + ).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('general.disableUpdateNag'); + }); + + it('should remove object value for disable key and emit warning', () => { + const v2Settings = { + $version: 2, + general: { disableAutoUpdate: { nested: 'value' } }, + }; + + const { settings: result, warnings } = migration.migrate( + v2Settings, + 'user', + ) as { + settings: Record; + warnings: string[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['general'] as Record)['disableAutoUpdate'], + ).toBeUndefined(); + expect( + (result['general'] as Record)['enableAutoUpdate'], + ).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('general.disableAutoUpdate'); + }); + + it('should remove array value for disable key and emit warning', () => { + const v2Settings = { + $version: 2, + general: { disableAutoUpdate: [1, 2, 3] }, + }; + + const { settings: result, warnings } = migration.migrate( + v2Settings, + 'user', + ) as { + settings: Record; + warnings: string[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['general'] as Record)['disableAutoUpdate'], + ).toBeUndefined(); + expect( + (result['general'] as Record)['enableAutoUpdate'], + ).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('general.disableAutoUpdate'); + }); + + it('should remove null value for disable key and emit warning', () => { + const v2Settings = { + $version: 2, + general: { disableAutoUpdate: null }, + }; + + const { settings: result, warnings } = migration.migrate( + v2Settings, + 'user', + ) as { + settings: Record; + warnings: string[]; + }; + + expect(result['$version']).toBe(3); + expect( + (result['general'] as Record)['disableAutoUpdate'], + ).toBeUndefined(); + expect( + (result['general'] as Record)['enableAutoUpdate'], + ).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('general.disableAutoUpdate'); + }); + }); + + describe('version properties', () => { + it('should have correct fromVersion', () => { + expect(migration.fromVersion).toBe(2); + }); + + it('should have correct toVersion', () => { + expect(migration.toVersion).toBe(3); + }); + }); +}); diff --git a/packages/cli/src/config/migration/versions/v2-to-v3.ts b/packages/cli/src/config/migration/versions/v2-to-v3.ts new file mode 100644 index 000000000..6c0133443 --- /dev/null +++ b/packages/cli/src/config/migration/versions/v2-to-v3.ts @@ -0,0 +1,222 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SettingsMigration } from '../types.js'; +import { + deleteNestedPropertySafe, + getNestedProperty, + setNestedPropertySafe, +} from '../../../utils/settingsUtils.js'; + +/** + * Path mapping for boolean polarity migration (V2 disable* -> V3 enable*). + * + * Strategy: + * - For each mapped path, values are normalized before migration: + * - boolean values are accepted directly + * - string values "true"/"false" (case-insensitive, trim-aware) are coerced + * - all other present values are treated as invalid + * - Transformation is inversion-based: disable=true -> enable=false, disable=false -> enable=true. + * - Deprecated disable* keys are removed whenever present (valid or invalid). + * - Invalid values do not create enable* keys and produce warnings. + */ +const V2_TO_V3_BOOLEAN_MAP: Record = { + 'general.disableAutoUpdate': 'general.enableAutoUpdate', + 'general.disableUpdateNag': 'general.enableAutoUpdate', + 'ui.accessibility.disableLoadingPhrases': + 'ui.accessibility.enableLoadingPhrases', + 'context.fileFiltering.disableFuzzySearch': + 'context.fileFiltering.enableFuzzySearch', + 'model.generationConfig.disableCacheControl': + 'model.generationConfig.enableCacheControl', +}; + +/** + * Consolidated old paths that collapse into one V3 field. + * + * Current policy: + * - `general.disableAutoUpdate` and `general.disableUpdateNag` both drive + * `general.enableAutoUpdate`. + * - If any valid normalized source is true, target becomes false. + * - If at least one valid normalized source exists, consolidated target is emitted. + * - Invalid present values are removed and warned, and do not contribute to target calculation. + */ +const CONSOLIDATED_V2_PATHS: Record = { + 'general.enableAutoUpdate': [ + 'general.disableAutoUpdate', + 'general.disableUpdateNag', + ], +}; + +/** + * Normalizes deprecated disable* values for migration. + * + * Returns: + * - `isPresent=false` when the path does not exist + * - `isPresent=true, isValid=true` when value is boolean or coercible string + * - `isPresent=true, isValid=false` for invalid values (number/object/array/null/other strings) + */ +function normalizeDisableValue(value: unknown): { + isPresent: boolean; + isValid: boolean; + booleanValue?: boolean; +} { + if (value === undefined) { + return { isPresent: false, isValid: false }; + } + if (typeof value === 'boolean') { + return { isPresent: true, isValid: true, booleanValue: value }; + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') { + return { isPresent: true, isValid: true, booleanValue: true }; + } + if (normalized === 'false') { + return { isPresent: true, isValid: true, booleanValue: false }; + } + } + return { isPresent: true, isValid: false }; +} + +/** + * V2 -> V3 migration (boolean polarity normalization stage). + * + * Migration contract: + * - Input: V2 settings object (`$version: 2`). + * - Output: `$version: 3` with deprecated disable* fields removed and + * valid values migrated to enable* equivalents. + * + * Compatibility strategy: + * - Accept boolean values and coercible strings "true"/"false". + * - Remove invalid deprecated values (rather than preserving them). + * - Emit warnings for each removed invalid deprecated key. + * - Always bump version to 3 so future loads are idempotent and skip repeated checks. + */ +export class V2ToV3Migration implements SettingsMigration { + readonly fromVersion = 2; + readonly toVersion = 3; + + /** + * Migration trigger rule. + * + * Execute only when `$version === 2`. + * This includes V2 files with no migratable disable* booleans so that version + * metadata still advances to 3. + */ + shouldMigrate(settings: unknown): boolean { + if (typeof settings !== 'object' || settings === null) { + return false; + } + + const s = settings as Record; + + // Migrate if $version is 2 + return s['$version'] === 2; + } + + /** + * Applies V2 -> V3 transformation with deterministic deprecated-key cleanup. + * + * Detailed strategy: + * 1) Clone input. + * 2) Process consolidated paths first: + * - Inspect each source path. + * - Normalize each present value (boolean / coercible string / invalid). + * - Always delete present deprecated source key. + * - Valid normalized values contribute to aggregate. + * - Invalid values emit warnings. + * - Emit consolidated target when at least one valid source was consumed. + * 3) Process remaining one-to-one mappings: + * - For each unmapped source, normalize value. + * - If valid -> delete old key and write inverted target. + * - If invalid -> delete old key and emit warning. + * 4) Set `$version = 3`. + * + * Guarantees: + * - Input object is not mutated. + * - Valid migration and invalid cleanup are deterministic. + * - Deprecated disable* keys are not retained after migration. + */ + migrate( + settings: unknown, + scope: string, + ): { settings: unknown; warnings: string[] } { + if (typeof settings !== 'object' || settings === null) { + throw new Error('Settings must be an object'); + } + + // Deep clone to avoid mutating input + const result = structuredClone(settings) as Record; + const processedPaths = new Set(); + const warnings: string[] = []; + + // Step 1: Handle consolidated paths (multiple old paths → single new path) + // Policy: if ANY of the old disable* settings is true, the new enable* should be false + for (const [newPath, oldPaths] of Object.entries(CONSOLIDATED_V2_PATHS)) { + let hasAnyDisable = false; + let hasAnyBooleanValue = false; + + for (const oldPath of oldPaths) { + const oldValue = getNestedProperty(result, oldPath); + const normalized = normalizeDisableValue(oldValue); + if (!normalized.isPresent) { + continue; + } + + deleteNestedPropertySafe(result, oldPath); + processedPaths.add(oldPath); + + if (normalized.isValid) { + hasAnyBooleanValue = true; + if (normalized.booleanValue === true) { + hasAnyDisable = true; + } + } else { + warnings.push( + `Removed deprecated setting '${oldPath}' from ${scope} settings because the value is invalid. Expected boolean.`, + ); + } + } + + if (hasAnyBooleanValue) { + // enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false) + setNestedPropertySafe(result, newPath, !hasAnyDisable); + } + } + + // Step 2: Handle remaining individual disable* → enable* mappings + for (const [oldPath, newPath] of Object.entries(V2_TO_V3_BOOLEAN_MAP)) { + if (processedPaths.has(oldPath)) { + continue; + } + + const oldValue = getNestedProperty(result, oldPath); + const normalized = normalizeDisableValue(oldValue); + if (!normalized.isPresent) { + continue; + } + + deleteNestedPropertySafe(result, oldPath); + if (normalized.isValid) { + // Set new property with inverted value + setNestedPropertySafe(result, newPath, !normalized.booleanValue); + } else { + warnings.push( + `Removed deprecated setting '${oldPath}' from ${scope} settings because the value is invalid. Expected boolean or string "true"/"false".`, + ); + } + } + + // Step 3: Always update version to 3 + result['$version'] = 3; + + return { settings: result, warnings }; + } +} + +/** Singleton instance of V2→V3 migration */ +export const v2ToV3Migration = new V2ToV3Migration(); diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index bea89475f..d4241c7ba 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -18,16 +18,6 @@ vi.mock('os', async (importOriginal) => { }; }); -// Mock './settings.js' to ensure it uses the mocked 'os.homedir()' for its internal constants. -vi.mock('./settings.js', async (importActual) => { - const originalModule = await importActual(); - return { - __esModule: true, // Ensure correct module shape - ...originalModule, // Re-export all original members - // We are relying on originalModule's USER_SETTINGS_PATH being constructed with mocked os.homedir() - }; -}); - // Mock trustedFolders vi.mock('./trustedFolders.js', () => ({ isWorkspaceTrusted: vi @@ -46,7 +36,6 @@ import { afterEach, type Mocked, type Mock, - fail, } from 'vitest'; import * as fs from 'node:fs'; // fs will be mocked separately import stripJsonComments from 'strip-json-comments'; // Will be mocked separately @@ -60,13 +49,12 @@ import { getSystemSettingsPath, getSystemDefaultsPath, SETTINGS_DIRECTORY_NAME, // This is from the original module, but used by the mock. - migrateSettingsToV1, - needsMigration, type Settings, loadEnvironment, SETTINGS_VERSION, SETTINGS_VERSION_KEY, } from './settings.js'; +import { needsMigration } from './migration/index.js'; import { FatalConfigError, QWEN_DIR } from '@qwen-code/qwen-code-core'; const MOCK_WORKSPACE_DIR = '/mock/workspace'; @@ -84,6 +72,23 @@ type TestSettings = Settings & { nestedObj?: { [key: string]: unknown }; }; +vi.mock('node:fs', async (importOriginal) => { + // Get all the functions from the real 'fs' module + const actualFs = await importOriginal(); + + return { + ...actualFs, // Keep all the real functions + // Now, just override the ones we need for the test + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + renameSync: vi.fn(), + mkdirSync: vi.fn(), + realpathSync: (p: string) => p, + }; +}); + +// Also mock 'fs' for compatibility vi.mock('fs', async (importOriginal) => { // Get all the functions from the real 'fs' module const actualFs = await importOriginal(); @@ -594,19 +599,22 @@ describe('Settings Loading and Merging', () => { loadSettings(MOCK_WORKSPACE_DIR); - // Verify that fs.writeFileSync was called (to add version) - // but NOT fs.renameSync (no backup needed, just adding version) - expect(fs.renameSync).not.toHaveBeenCalled(); - expect(fs.writeFileSync).toHaveBeenCalledTimes(1); - - const writeCall = (fs.writeFileSync as Mock).mock.calls[0]; - const writtenPath = writeCall[0]; + // Version normalization now uses writeWithBackupSync (temp write + rename) + // Verify that writeFileSync was called with the temp file path + const writeCall = (fs.writeFileSync as Mock).mock.calls.find( + (call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`, + ); + expect(writeCall).toBeDefined(); + if (!writeCall) { + throw new Error('Expected temp write call for version normalization'); + } const writtenContent = JSON.parse(writeCall[1] as string); - expect(writtenPath).toBe(USER_SETTINGS_PATH); expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION); expect(writtenContent.ui?.theme).toBe('dark'); expect(writtenContent.model?.name).toBe('qwen-coder'); + // Verify writeWithBackupSync was called by checking temp file write + expect(fs.writeFileSync).toHaveBeenCalled(); }); it('should correctly handle partially migrated settings without version field', () => { @@ -734,14 +742,85 @@ describe('Settings Loading and Merging', () => { loadSettings(MOCK_WORKSPACE_DIR); // Version should be bumped to 3 even though no keys needed migration + // writeWithBackupSync writes to a temp file first, then renames const writeCall = (fs.writeFileSync as Mock).mock.calls.find( - (call: unknown[]) => call[0] === USER_SETTINGS_PATH, + (call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`, ); expect(writeCall).toBeDefined(); + if (!writeCall) { + throw new Error('Expected temp write call for V2->V3 version bump'); + } const writtenContent = JSON.parse(writeCall[1] as string); expect(writtenContent.$version).toBe(SETTINGS_VERSION); }); + it('should normalize invalid version metadata when no migration is applicable', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + const invalidVersionSettings = { + $version: 'invalid-version', + general: { + enableAutoUpdate: true, + }, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(invalidVersionSettings); + return '{}'; + }, + ); + + loadSettings(MOCK_WORKSPACE_DIR); + + const writeCall = (fs.writeFileSync as Mock).mock.calls.find( + (call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`, + ); + expect(writeCall).toBeDefined(); + if (!writeCall) { + throw new Error( + 'Expected temp write call for invalid version normalization', + ); + } + const writtenContent = JSON.parse(writeCall[1] as string); + expect(writtenContent.$version).toBe(SETTINGS_VERSION); + expect(writtenContent.general?.enableAutoUpdate).toBe(true); + }); + + it('should normalize legacy numeric version when no migration can execute', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + const staleVersionSettings = { + $version: 1, + // No V1/V2 indicators recognized by migrations + customOnlyKey: 'value', + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(staleVersionSettings); + return '{}'; + }, + ); + + loadSettings(MOCK_WORKSPACE_DIR); + + const writeCall = (fs.writeFileSync as Mock).mock.calls.find( + (call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`, + ); + expect(writeCall).toBeDefined(); + if (!writeCall) { + throw new Error( + 'Expected temp write call for stale version normalization', + ); + } + const writtenContent = JSON.parse(writeCall[1] as string); + expect(writtenContent.$version).toBe(SETTINGS_VERSION); + expect(writtenContent.customOnlyKey).toBe('value'); + }); + it('should correctly merge and migrate legacy array properties from multiple scopes', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const legacyUserSettings = { @@ -1619,7 +1698,7 @@ describe('Settings Loading and Merging', () => { try { loadSettings(MOCK_WORKSPACE_DIR); - fail('loadSettings should have thrown a FatalConfigError'); + throw new Error('loadSettings should have thrown a FatalConfigError'); } catch (e) { expect(e).toBeInstanceOf(FatalConfigError); const error = e as FatalConfigError; @@ -2261,385 +2340,6 @@ describe('Settings Loading and Merging', () => { }); }); - describe('migrateSettingsToV1', () => { - it('should handle an empty object', () => { - const v2Settings = {}; - const v1Settings = migrateSettingsToV1(v2Settings); - expect(v1Settings).toEqual({}); - }); - - it('should migrate a simple v2 settings object to v1', () => { - const v2Settings = { - general: { - preferredEditor: 'vscode', - vimMode: true, - }, - ui: { - theme: 'dark', - }, - }; - const v1Settings = migrateSettingsToV1(v2Settings); - expect(v1Settings).toEqual({ - preferredEditor: 'vscode', - vimMode: true, - theme: 'dark', - }); - }); - - it('should handle nested properties correctly', () => { - const v2Settings = { - security: { - folderTrust: { - enabled: true, - }, - auth: { - selectedType: 'oauth', - }, - }, - advanced: { - autoConfigureMemory: true, - }, - }; - const v1Settings = migrateSettingsToV1(v2Settings); - expect(v1Settings).toEqual({ - folderTrust: true, - selectedAuthType: 'oauth', - autoConfigureMaxOldSpaceSize: true, - }); - }); - - it('should preserve mcpServers at the top level', () => { - const v2Settings = { - general: { - preferredEditor: 'vscode', - }, - mcpServers: { - 'my-server': { - command: 'npm start', - }, - }, - }; - const v1Settings = migrateSettingsToV1(v2Settings); - expect(v1Settings).toEqual({ - preferredEditor: 'vscode', - mcpServers: { - 'my-server': { - command: 'npm start', - }, - }, - }); - }); - - it('should carry over unrecognized top-level properties', () => { - const v2Settings = { - general: { - vimMode: false, - }, - unrecognized: 'value', - another: { - nested: true, - }, - }; - const v1Settings = migrateSettingsToV1(v2Settings); - expect(v1Settings).toEqual({ - vimMode: false, - unrecognized: 'value', - another: { - nested: true, - }, - }); - }); - - it('should handle a complex object with mixed properties', () => { - const v2Settings = { - general: { - disableAutoUpdate: true, - }, - ui: { - hideTips: true, - customThemes: { - myTheme: {}, - }, - }, - model: { - name: 'gemini-pro', - chatCompression: { - contextPercentageThreshold: 0.5, - }, - }, - mcpServers: { - 'server-1': { - command: 'node server.js', - }, - }, - unrecognized: { - should: 'be-preserved', - }, - }; - const v1Settings = migrateSettingsToV1(v2Settings); - expect(v1Settings).toEqual({ - disableAutoUpdate: true, - hideTips: true, - customThemes: { - myTheme: {}, - }, - model: 'gemini-pro', - chatCompression: { - contextPercentageThreshold: 0.5, - }, - mcpServers: { - 'server-1': { - command: 'node server.js', - }, - }, - unrecognized: { - should: 'be-preserved', - }, - }); - }); - - it('should not migrate a v1 settings object', () => { - const v1Settings = { - preferredEditor: 'vscode', - vimMode: true, - theme: 'dark', - }; - const migratedSettings = migrateSettingsToV1(v1Settings); - expect(migratedSettings).toEqual({ - preferredEditor: 'vscode', - vimMode: true, - theme: 'dark', - }); - }); - - it('should migrate a full v2 settings object to v1', () => { - const v2Settings: TestSettings = { - general: { - preferredEditor: 'code', - vimMode: true, - }, - ui: { - theme: 'dark', - }, - privacy: { - usageStatisticsEnabled: false, - }, - model: { - name: 'gemini-pro', - chatCompression: { - contextPercentageThreshold: 0.8, - }, - }, - context: { - fileName: 'CONTEXT.md', - includeDirectories: ['/src'], - }, - tools: { - sandbox: true, - exclude: ['toolA'], - }, - mcp: { - allowed: ['server1'], - }, - security: { - folderTrust: { - enabled: true, - }, - }, - advanced: { - dnsResolutionOrder: 'ipv4first', - excludedEnvVars: ['SECRET'], - }, - mcpServers: { - 'my-server': { - command: 'npm start', - }, - }, - unrecognizedTopLevel: { - value: 'should be preserved', - }, - }; - - const v1Settings = migrateSettingsToV1(v2Settings); - - expect(v1Settings).toEqual({ - preferredEditor: 'code', - vimMode: true, - theme: 'dark', - usageStatisticsEnabled: false, - model: 'gemini-pro', - chatCompression: { - contextPercentageThreshold: 0.8, - }, - contextFileName: 'CONTEXT.md', - includeDirectories: ['/src'], - sandbox: true, - excludeTools: ['toolA'], - allowMCPServers: ['server1'], - folderTrust: true, - dnsResolutionOrder: 'ipv4first', - excludedProjectEnvVars: ['SECRET'], - mcpServers: { - 'my-server': { - command: 'npm start', - }, - }, - unrecognizedTopLevel: { - value: 'should be preserved', - }, - }); - }); - - it('should handle partial v2 settings', () => { - const v2Settings: TestSettings = { - general: { - vimMode: false, - }, - ui: {}, - model: { - name: 'gemini-1.5-pro', - }, - unrecognized: 'value', - }; - - const v1Settings = migrateSettingsToV1(v2Settings); - - expect(v1Settings).toEqual({ - vimMode: false, - model: 'gemini-1.5-pro', - unrecognized: 'value', - }); - }); - - it('should handle settings with different data types', () => { - const v2Settings: TestSettings = { - general: { - vimMode: false, - }, - model: { - maxSessionTurns: -1, - }, - context: { - includeDirectories: [], - }, - security: { - folderTrust: { - enabled: false, - }, - }, - }; - - const v1Settings = migrateSettingsToV1(v2Settings); - - expect(v1Settings).toEqual({ - vimMode: false, - maxSessionTurns: -1, - includeDirectories: [], - folderTrust: false, - }); - }); - - it('should preserve unrecognized top-level keys', () => { - const v2Settings: TestSettings = { - general: { - vimMode: true, - }, - customTopLevel: { - a: 1, - b: [2], - }, - anotherOne: 'hello', - }; - - const v1Settings = migrateSettingsToV1(v2Settings); - - expect(v1Settings).toEqual({ - vimMode: true, - customTopLevel: { - a: 1, - b: [2], - }, - anotherOne: 'hello', - }); - }); - - it('should handle an empty v2 settings object', () => { - const v2Settings = {}; - const v1Settings = migrateSettingsToV1(v2Settings); - expect(v1Settings).toEqual({}); - }); - - it('should correctly handle mcpServers at the top level', () => { - const v2Settings: TestSettings = { - mcpServers: { - serverA: { command: 'a' }, - }, - mcp: { - allowed: ['serverA'], - }, - }; - - const v1Settings = migrateSettingsToV1(v2Settings); - - expect(v1Settings).toEqual({ - mcpServers: { - serverA: { command: 'a' }, - }, - allowMCPServers: ['serverA'], - }); - }); - - it('should correctly migrate customWittyPhrases', () => { - const v2Settings: Partial = { - ui: { - customWittyPhrases: ['test phrase'], - }, - }; - const v1Settings = migrateSettingsToV1(v2Settings as Settings); - expect(v1Settings).toEqual({ - customWittyPhrases: ['test phrase'], - }); - }); - - it('should remove version field when migrating to V1', () => { - const v2Settings = { - [SETTINGS_VERSION_KEY]: SETTINGS_VERSION, - ui: { - theme: 'dark', - }, - model: { - name: 'qwen-coder', - }, - }; - const v1Settings = migrateSettingsToV1(v2Settings); - - // Version field should not be present in V1 settings - expect(v1Settings[SETTINGS_VERSION_KEY]).toBeUndefined(); - // Other fields should be properly migrated - expect(v1Settings).toEqual({ - theme: 'dark', - model: 'qwen-coder', - }); - }); - - it('should handle version field in unrecognized properties', () => { - const v2Settings = { - [SETTINGS_VERSION_KEY]: SETTINGS_VERSION, - general: { - vimMode: true, - }, - someUnrecognizedKey: 'value', - }; - const v1Settings = migrateSettingsToV1(v2Settings); - - // Version field should be filtered out - expect(v1Settings[SETTINGS_VERSION_KEY]).toBeUndefined(); - // Unrecognized keys should be preserved - expect(v1Settings['someUnrecognizedKey']).toBe('value'); - expect(v1Settings['vimMode']).toBe(true); - }); - }); - describe('loadEnvironment', () => { function setup({ isFolderTrustEnabled = true, diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index fe2f63bd1..bfc670b60 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -14,6 +14,9 @@ import { QWEN_DIR, getErrorMessage, Storage, + setDebugLogSession, + sanitizeCwd, + createDebugLogger, } from '@qwen-code/qwen-code-core'; import stripJsonComments from 'strip-json-comments'; import { DefaultLight } from '../ui/themes/default-light.js'; @@ -28,9 +31,16 @@ import { getSettingsSchema, } from './settingsSchema.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; -import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js'; +import { setNestedPropertySafe } from '../utils/settingsUtils.js'; +import { customDeepMerge } from '../utils/deepMerge.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; -import { writeStderrLine } from '../utils/stdioHelpers.js'; +const debugLogger = createDebugLogger('SETTINGS'); +import { runMigrations, needsMigration } from './migration/index.js'; +import { + V1_TO_V2_MIGRATION_MAP, + V2_CONTAINER_KEYS, +} from './migration/versions/v1-to-v2-shared.js'; +import { writeWithBackupSync } from '../utils/writeWithBackup.js'; function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { let current: SettingDefinition | undefined = undefined; @@ -54,115 +64,10 @@ export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath(); export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH); export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE']; -const MIGRATE_V2_OVERWRITE = true; - // Settings version to track migration state export const SETTINGS_VERSION = 3; export const SETTINGS_VERSION_KEY = '$version'; -const MIGRATION_MAP: Record = { - accessibility: 'ui.accessibility', - allowedTools: 'tools.allowed', - allowMCPServers: 'mcp.allowed', - autoAccept: 'tools.autoAccept', - autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory', - bugCommand: 'advanced.bugCommand', - chatCompression: 'model.chatCompression', - checkpointing: 'general.checkpointing', - coreTools: 'tools.core', - contextFileName: 'context.fileName', - customThemes: 'ui.customThemes', - customWittyPhrases: 'ui.customWittyPhrases', - debugKeystrokeLogging: 'general.debugKeystrokeLogging', - dnsResolutionOrder: 'advanced.dnsResolutionOrder', - enforcedAuthType: 'security.auth.enforcedType', - excludeTools: 'tools.exclude', - excludeMCPServers: 'mcp.excluded', - excludedProjectEnvVars: 'advanced.excludedEnvVars', - extensions: 'extensions', - fileFiltering: 'context.fileFiltering', - folderTrustFeature: 'security.folderTrust.featureEnabled', - folderTrust: 'security.folderTrust.enabled', - hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge', - hideWindowTitle: 'ui.hideWindowTitle', - showStatusInTitle: 'ui.showStatusInTitle', - hideTips: 'ui.hideTips', - showLineNumbers: 'ui.showLineNumbers', - showCitations: 'ui.showCitations', - ideMode: 'ide.enabled', - includeDirectories: 'context.includeDirectories', - loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories', - maxSessionTurns: 'model.maxSessionTurns', - mcpServers: 'mcpServers', - mcpServerCommand: 'mcp.serverCommand', - memoryImportFormat: 'context.importFormat', - model: 'model.name', - preferredEditor: 'general.preferredEditor', - sandbox: 'tools.sandbox', - selectedAuthType: 'security.auth.selectedType', - shouldUseNodePtyShell: 'tools.shell.enableInteractiveShell', - shellPager: 'tools.shell.pager', - shellShowColor: 'tools.shell.showColor', - skipNextSpeakerCheck: 'model.skipNextSpeakerCheck', - summarizeToolOutput: 'model.summarizeToolOutput', - telemetry: 'telemetry', - theme: 'ui.theme', - toolDiscoveryCommand: 'tools.discoveryCommand', - toolCallCommand: 'tools.callCommand', - usageStatisticsEnabled: 'privacy.usageStatisticsEnabled', - useExternalAuth: 'security.auth.useExternal', - useRipgrep: 'tools.useRipgrep', - vimMode: 'general.vimMode', - - enableWelcomeBack: 'ui.enableWelcomeBack', - approvalMode: 'tools.approvalMode', - sessionTokenLimit: 'model.sessionTokenLimit', - contentGenerator: 'model.generationConfig', - skipLoopDetection: 'model.skipLoopDetection', - skipStartupContext: 'model.skipStartupContext', - enableOpenAILogging: 'model.enableOpenAILogging', - tavilyApiKey: 'advanced.tavilyApiKey', - vlmSwitchMode: 'experimental.vlmSwitchMode', - visionModelPreview: 'experimental.visionModelPreview', -}; - -// Settings that need boolean inversion during migration (V1 -> V3) -// Old negative naming -> new positive naming with inverted value -const INVERTED_BOOLEAN_MIGRATIONS: Record = { - disableAutoUpdate: 'general.enableAutoUpdate', - disableUpdateNag: 'general.enableAutoUpdate', - disableLoadingPhrases: 'ui.accessibility.enableLoadingPhrases', - disableFuzzySearch: 'context.fileFiltering.enableFuzzySearch', - disableCacheControl: 'model.generationConfig.enableCacheControl', -}; - -// Consolidated settings: multiple old V1 keys that map to a single new key. -// Policy: if ANY of the old disable* settings is true, the new enable* should be false. -const CONSOLIDATED_SETTINGS: Record = { - 'general.enableAutoUpdate': ['disableAutoUpdate', 'disableUpdateNag'], -}; - -// V2 nested paths that need inversion when migrating to V3 -const INVERTED_V2_PATHS: Record = { - 'general.disableAutoUpdate': 'general.enableAutoUpdate', - 'general.disableUpdateNag': 'general.enableAutoUpdate', - 'ui.accessibility.disableLoadingPhrases': - 'ui.accessibility.enableLoadingPhrases', - 'context.fileFiltering.disableFuzzySearch': - 'context.fileFiltering.enableFuzzySearch', - 'model.generationConfig.disableCacheControl': - 'model.generationConfig.enableCacheControl', -}; - -// Consolidated V2 paths: multiple old paths that map to a single new path. -// Policy: if ANY of the old disable* settings is true, the new enable* should be false. -const CONSOLIDATED_V2_PATHS: Record = { - 'general.enableAutoUpdate': [ - 'general.disableAutoUpdate', - 'general.disableUpdateNag', - ], -}; - export function getSystemSettingsPath(): string { if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) { return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']; @@ -220,312 +125,6 @@ export interface SettingsFile { rawJson?: string; } -function setNestedProperty( - obj: Record, - path: string, - value: unknown, -) { - const keys = path.split('.'); - const lastKey = keys.pop(); - if (!lastKey) return; - - let current: Record = obj; - for (const key of keys) { - if (current[key] === undefined) { - current[key] = {}; - } - const next = current[key]; - if (typeof next === 'object' && next !== null) { - current = next as Record; - } else { - // This path is invalid, so we stop. - return; - } - } - current[lastKey] = value; -} - -// Dynamically determine the top-level keys from the V2 settings structure. -const KNOWN_V2_CONTAINERS = new Set([ - ...Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]), - ...Object.values(INVERTED_BOOLEAN_MIGRATIONS).map( - (path) => path.split('.')[0], - ), -]); - -export function needsMigration(settings: Record): boolean { - // Check version field first - if present and matches current version, no migration needed - if (SETTINGS_VERSION_KEY in settings) { - const version = settings[SETTINGS_VERSION_KEY]; - if (typeof version === 'number' && version >= SETTINGS_VERSION) { - return false; - } - } - - // Fallback to legacy detection: A file needs migration if it contains any - // top-level key that is moved to a nested location in V2. - const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => { - if (v1Key === v2Path || !(v1Key in settings)) { - return false; - } - // If a key exists that is both a V1 key and a V2 container (like 'model'), - // we need to check the type. If it's an object, it's a V2 container and not - // a V1 key that needs migration. - if ( - KNOWN_V2_CONTAINERS.has(v1Key) && - typeof settings[v1Key] === 'object' && - settings[v1Key] !== null - ) { - return false; - } - return true; - }); - - // Also check for old inverted boolean keys (disable* -> enable*) - const hasInvertedBooleanKeys = Object.keys(INVERTED_BOOLEAN_MIGRATIONS).some( - (v1Key) => v1Key in settings, - ); - - return hasV1Keys || hasInvertedBooleanKeys; -} - -/** - * Migrates V1 (flat) settings directly to V3. - * This includes both structural migration (flat -> nested) and boolean - * inversion (disable* -> enable*), so migrateV2ToV3 will be skipped. - */ -function migrateV1ToV3( - flatSettings: Record, -): Record | null { - if (!needsMigration(flatSettings)) { - return null; - } - - const v2Settings: Record = {}; - const flatKeys = new Set(Object.keys(flatSettings)); - - for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) { - if (flatKeys.has(oldKey)) { - // Safety check: If this key is a V2 container (like 'model') and it's - // already an object, it's likely already in V2 format. Skip migration - // to prevent double-nesting (e.g., model.name.name). - if ( - KNOWN_V2_CONTAINERS.has(oldKey) && - typeof flatSettings[oldKey] === 'object' && - flatSettings[oldKey] !== null && - !Array.isArray(flatSettings[oldKey]) - ) { - // This is already a V2 container, carry it over as-is - v2Settings[oldKey] = flatSettings[oldKey]; - flatKeys.delete(oldKey); - continue; - } - - setNestedProperty(v2Settings, newPath, flatSettings[oldKey]); - flatKeys.delete(oldKey); - } - } - - // Handle consolidated settings first (multiple old keys -> single new key) - // Policy: if ANY of the old disable* settings is true, the new enable* should be false - for (const [newPath, oldKeys] of Object.entries(CONSOLIDATED_SETTINGS)) { - let hasAnyDisable = false; - let hasAnyValue = false; - for (const oldKey of oldKeys) { - if (flatKeys.has(oldKey)) { - hasAnyValue = true; - const oldValue = flatSettings[oldKey]; - if (typeof oldValue === 'boolean' && oldValue === true) { - hasAnyDisable = true; - } - flatKeys.delete(oldKey); - } - } - if (hasAnyValue) { - // enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false) - setNestedProperty(v2Settings, newPath, !hasAnyDisable); - } - } - - // Handle remaining V1 settings that need boolean inversion (disable* -> enable*) - // Skip keys that were already handled by consolidated settings - const consolidatedKeys = new Set(Object.values(CONSOLIDATED_SETTINGS).flat()); - for (const [oldKey, newPath] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) { - if (consolidatedKeys.has(oldKey)) { - continue; - } - if (flatKeys.has(oldKey)) { - const oldValue = flatSettings[oldKey]; - if (typeof oldValue === 'boolean') { - setNestedProperty(v2Settings, newPath, !oldValue); - } - flatKeys.delete(oldKey); - } - } - - // Preserve mcpServers at the top level - if (flatSettings['mcpServers']) { - v2Settings['mcpServers'] = flatSettings['mcpServers']; - flatKeys.delete('mcpServers'); - } - - // Carry over any unrecognized keys - for (const remainingKey of flatKeys) { - const existingValue = v2Settings[remainingKey]; - const newValue = flatSettings[remainingKey]; - - if ( - typeof existingValue === 'object' && - existingValue !== null && - !Array.isArray(existingValue) && - typeof newValue === 'object' && - newValue !== null && - !Array.isArray(newValue) - ) { - const pathAwareGetStrategy = (path: string[]) => - getMergeStrategyForPath([remainingKey, ...path]); - v2Settings[remainingKey] = customDeepMerge( - pathAwareGetStrategy, - {}, - newValue as MergeableObject, - existingValue as MergeableObject, - ); - } else { - v2Settings[remainingKey] = newValue; - } - } - - // Set version field to indicate this is a V2 settings file - v2Settings[SETTINGS_VERSION_KEY] = SETTINGS_VERSION; - - return v2Settings; -} - -// Migrate V2 settings to V3 (invert disable* -> enable* booleans) -function migrateV2ToV3( - settings: Record, -): Record | null { - const version = settings[SETTINGS_VERSION_KEY]; - if (typeof version === 'number' && version >= 3) { - return null; - } - - let changed = false; - const result = structuredClone(settings); - const processedPaths = new Set(); - - // Handle consolidated V2 paths first (multiple old paths -> single new path) - // Policy: if ANY of the old disable* settings is true, the new enable* should be false - for (const [newPath, oldPaths] of Object.entries(CONSOLIDATED_V2_PATHS)) { - let hasAnyDisable = false; - let hasAnyValue = false; - for (const oldPath of oldPaths) { - const oldValue = getNestedProperty(result, oldPath); - if (typeof oldValue === 'boolean') { - hasAnyValue = true; - if (oldValue === true) { - hasAnyDisable = true; - } - deleteNestedProperty(result, oldPath); - processedPaths.add(oldPath); - changed = true; - } - } - if (hasAnyValue) { - // enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false) - setNestedProperty(result, newPath, !hasAnyDisable); - } - } - - // Handle remaining V2 paths that need inversion - for (const [oldPath, newPath] of Object.entries(INVERTED_V2_PATHS)) { - if (processedPaths.has(oldPath)) { - continue; - } - const oldValue = getNestedProperty(result, oldPath); - if (typeof oldValue === 'boolean') { - // Remove old property - deleteNestedProperty(result, oldPath); - // Set new property with inverted value - setNestedProperty(result, newPath, !oldValue); - changed = true; - } - } - - if (changed) { - result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION; - return result; - } - - // Even if no changes, bump version to 3 to skip future migration checks - if (typeof version === 'number' && version < SETTINGS_VERSION) { - result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION; - return result; - } - - return null; -} - -function deleteNestedProperty( - obj: Record, - path: string, -): void { - const keys = path.split('.'); - const lastKey = keys.pop(); - if (!lastKey) return; - - let current: Record = obj; - for (const key of keys) { - const next = current[key]; - if (typeof next !== 'object' || next === null) { - return; - } - current = next as Record; - } - delete current[lastKey]; -} - -function getNestedProperty( - obj: Record, - path: string, -): unknown { - const keys = path.split('.'); - let current: unknown = obj; - for (const key of keys) { - if (typeof current !== 'object' || current === null || !(key in current)) { - return undefined; - } - current = (current as Record)[key]; - } - return current; -} - -const REVERSE_MIGRATION_MAP: Record = Object.fromEntries( - Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]), -); - -// Reverse map for old V2 paths (before rename) to V1 keys. -// Used when migrating settings that still have old V2 naming (e.g., general.disableAutoUpdate). -const OLD_V2_TO_V1_MAP: Record = {}; -for (const [oldV2Path, newV3Path] of Object.entries(INVERTED_V2_PATHS)) { - // Find the V1 key that maps to this V3 path - for (const [v1Key, v3Path] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) { - if (v3Path === newV3Path) { - OLD_V2_TO_V1_MAP[oldV2Path] = v1Key; - break; - } - } -} - -// Reverse map for new V3 paths to V1 keys (with boolean inversion). -// Used when migrating settings that have new V3 naming (e.g., general.enableAutoUpdate). -const V3_TO_V1_INVERTED_MAP: Record = Object.fromEntries( - Object.entries(INVERTED_BOOLEAN_MIGRATIONS).map(([v1Key, v3Path]) => [ - v3Path, - v1Key, - ]), -); - function getSettingsFileKeyWarnings( settings: Record, settingsFilePath: string, @@ -539,7 +138,7 @@ function getSettingsFileKeyWarnings( const ignoredLegacyKeys = new Set(); // Ignored legacy keys (V1 top-level keys that moved to a nested V2 path). - for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) { + for (const [oldKey, newPath] of Object.entries(V1_TO_V2_MIGRATION_MAP)) { if (oldKey === newPath) { continue; } @@ -552,7 +151,7 @@ function getSettingsFileKeyWarnings( // If this key is a V2 container (like 'model') and it's already an object, // it's likely already in V2 format. Don't warn. if ( - KNOWN_V2_CONTAINERS.has(oldKey) && + V2_CONTAINER_KEYS.has(oldKey) && typeof oldValue === 'object' && oldValue !== null && !Array.isArray(oldValue) @@ -588,7 +187,8 @@ function getSettingsFileKeyWarnings( } /** - * Collects warnings for ignored legacy and unknown settings keys. + * Collects warnings for ignored legacy and unknown settings keys, + * as well as migration warnings. * * For `$version: 2` settings files, we do not apply implicit migrations. * Instead, we surface actionable, de-duplicated warnings in the terminal UI. @@ -596,6 +196,11 @@ function getSettingsFileKeyWarnings( export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] { const warningSet = new Set(); + // Add migration warnings first + for (const warning of loadedSettings.migrationWarnings) { + warningSet.add(`Warning: ${warning}`); + } + for (const scope of [SettingScope.User, SettingScope.Workspace]) { const settingsFile = loadedSettings.forScope(scope); if (settingsFile.rawJson === undefined) { @@ -618,75 +223,6 @@ export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] { return [...warningSet]; } -export function migrateSettingsToV1( - v2Settings: Record, -): Record { - const v1Settings: Record = {}; - const v2Keys = new Set(Object.keys(v2Settings)); - - for (const [newPath, oldKey] of Object.entries(REVERSE_MIGRATION_MAP)) { - const value = getNestedProperty(v2Settings, newPath); - if (value !== undefined) { - v1Settings[oldKey] = value; - v2Keys.delete(newPath.split('.')[0]); - } - } - - // Handle old V2 inverted paths (no value inversion needed) - // e.g., general.disableAutoUpdate -> disableAutoUpdate - for (const [oldV2Path, v1Key] of Object.entries(OLD_V2_TO_V1_MAP)) { - const value = getNestedProperty(v2Settings, oldV2Path); - if (value !== undefined) { - v1Settings[v1Key] = value; - v2Keys.delete(oldV2Path.split('.')[0]); - } - } - - // Handle new V3 inverted paths (WITH value inversion) - // e.g., general.enableAutoUpdate -> disableAutoUpdate (inverted) - for (const [v3Path, v1Key] of Object.entries(V3_TO_V1_INVERTED_MAP)) { - const value = getNestedProperty(v2Settings, v3Path); - if (value !== undefined && typeof value === 'boolean') { - v1Settings[v1Key] = !value; - v2Keys.delete(v3Path.split('.')[0]); - } - } - - // Preserve mcpServers at the top level - if (v2Settings['mcpServers']) { - v1Settings['mcpServers'] = v2Settings['mcpServers']; - v2Keys.delete('mcpServers'); - } - - // Carry over any unrecognized keys - for (const remainingKey of v2Keys) { - // Skip the version field - it's only for V2 format - if (remainingKey === SETTINGS_VERSION_KEY) { - continue; - } - - const value = v2Settings[remainingKey]; - if (value === undefined) { - continue; - } - - // Don't carry over empty objects that were just containers for migrated settings. - if ( - KNOWN_V2_CONTAINERS.has(remainingKey) && - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - Object.keys(value).length === 0 - ) { - continue; - } - - v1Settings[remainingKey] = value; - } - - return v1Settings; -} - function mergeSettings( system: Settings, systemDefaults: Settings, @@ -720,6 +256,7 @@ export class LoadedSettings { workspace: SettingsFile, isTrusted: boolean, migratedInMemorScopes: Set, + migrationWarnings: string[] = [], ) { this.system = system; this.systemDefaults = systemDefaults; @@ -727,6 +264,7 @@ export class LoadedSettings { this.workspace = workspace; this.isTrusted = isTrusted; this.migratedInMemorScopes = migratedInMemorScopes; + this.migrationWarnings = migrationWarnings; this._merged = this.computeMergedSettings(); } @@ -736,6 +274,7 @@ export class LoadedSettings { readonly workspace: SettingsFile; readonly isTrusted: boolean; readonly migratedInMemorScopes: Set; + readonly migrationWarnings: string[]; private _merged: Settings; @@ -770,8 +309,8 @@ export class LoadedSettings { setValue(scope: SettingScope, key: string, value: unknown): void { const settingsFile = this.forScope(scope); - setNestedProperty(settingsFile.settings, key, value); - setNestedProperty(settingsFile.originalSettings, key, value); + setNestedPropertySafe(settingsFile.settings, key, value); + setNestedPropertySafe(settingsFile.originalSettings, key, value); this._merged = this.computeMergedSettings(); saveSettings(settingsFile); } @@ -795,6 +334,7 @@ export function createMinimalSettings(): LoadedSettings { emptySettingsFile, false, new Set(), + [], ); } @@ -935,6 +475,16 @@ export function loadEnvironment(settings: Settings): void { export function loadSettings( workspaceDir: string = process.cwd(), ): LoadedSettings { + // Set up a temporary debug log session for the startup phase. + // This allows migration errors to be logged to file instead of being + // exposed to users via stderr. The Config class will override this + // with the actual session once initialized. + const resolvedWorkspaceDir = path.resolve(workspaceDir); + const sanitizedProjectId = sanitizeCwd(resolvedWorkspaceDir); + setDebugLogSession({ + getSessionId: () => `startup-${sanitizedProjectId}`, + }); + let systemSettings: Settings = {}; let systemDefaultSettings: Settings = {}; let userSettings: Settings = {}; @@ -945,7 +495,7 @@ export function loadSettings( const migratedInMemorScopes = new Set(); // Resolve paths to their canonical representation to handle symlinks - const resolvedWorkspaceDir = path.resolve(workspaceDir); + // Note: resolvedWorkspaceDir is already defined at the top of the function const resolvedHomeDir = path.resolve(homedir()); let realWorkspaceDir = resolvedWorkspaceDir; @@ -966,7 +516,7 @@ export function loadSettings( const loadAndMigrate = ( filePath: string, scope: SettingScope, - ): { settings: Settings; rawJson?: string } => { + ): { settings: Settings; rawJson?: string; migrationWarnings?: string[] } => { try { if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, 'utf-8'); @@ -985,74 +535,59 @@ export function loadSettings( } let settingsObject = rawSettings as Record; + const hasVersionKey = SETTINGS_VERSION_KEY in settingsObject; + const versionValue = settingsObject[SETTINGS_VERSION_KEY]; + const hasInvalidVersion = + hasVersionKey && typeof versionValue !== 'number'; + const hasLegacyNumericVersion = + typeof versionValue === 'number' && versionValue < SETTINGS_VERSION; + let migrationWarnings: string[] | undefined; + + const persistSettingsObject = (warningPrefix: string) => { + try { + writeWithBackupSync( + filePath, + JSON.stringify(settingsObject, null, 2), + ); + } catch (e) { + debugLogger.error(`${warningPrefix}: ${getErrorMessage(e)}`); + } + }; + if (needsMigration(settingsObject)) { - const migratedSettings = migrateV1ToV3(settingsObject); - if (migratedSettings) { - if (MIGRATE_V2_OVERWRITE) { - try { - fs.renameSync(filePath, `${filePath}.orig`); - fs.writeFileSync( - filePath, - JSON.stringify(migratedSettings, null, 2), - 'utf-8', - ); - } catch (e) { - writeStderrLine( - `Error migrating settings file on disk: ${getErrorMessage( - e, - )}`, - ); - } - } else { - migratedInMemorScopes.add(scope); - } - settingsObject = migratedSettings; + const migrationResult = runMigrations(settingsObject, scope); + if (migrationResult.executedMigrations.length > 0) { + settingsObject = migrationResult.settings as Record< + string, + unknown + >; + migrationWarnings = migrationResult.warnings; + persistSettingsObject('Error migrating settings file on disk'); + } else if (hasLegacyNumericVersion || hasInvalidVersion) { + // Migration was deemed needed but nothing executed. Normalize version metadata + // to avoid repeated no-op checks on startup. + settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION; + debugLogger.warn( + `Settings version metadata in ${filePath} could not be migrated by any registered migration. Normalizing ${SETTINGS_VERSION_KEY} to ${SETTINGS_VERSION}.`, + ); + persistSettingsObject('Error normalizing settings version on disk'); } - } else if (!(SETTINGS_VERSION_KEY in settingsObject)) { - // No migration needed, but version field is missing - add it for future optimizations + } else if ( + !hasVersionKey || + hasInvalidVersion || + hasLegacyNumericVersion + ) { + // No migration needed/executable, but version metadata is missing or invalid. + // Normalize it to current version to avoid repeated startup work. settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION; - if (MIGRATE_V2_OVERWRITE) { - try { - fs.writeFileSync( - filePath, - JSON.stringify(settingsObject, null, 2), - 'utf-8', - ); - } catch (e) { - writeStderrLine( - `Error adding version to settings file: ${getErrorMessage(e)}`, - ); - } - } + persistSettingsObject('Error normalizing settings version on disk'); } - // V2 to V3 migration (invert disable* -> enable* booleans) - const v3Migrated = migrateV2ToV3(settingsObject); - if (v3Migrated) { - if (MIGRATE_V2_OVERWRITE) { - try { - // Only backup if not already backed up by V1->V2 migration - const backupPath = `${filePath}.orig`; - if (!fs.existsSync(backupPath)) { - fs.renameSync(filePath, backupPath); - } - fs.writeFileSync( - filePath, - JSON.stringify(v3Migrated, null, 2), - 'utf-8', - ); - } catch (e) { - writeStderrLine( - `Error migrating settings file to V3: ${getErrorMessage(e)}`, - ); - } - } else { - migratedInMemorScopes.add(scope); - } - settingsObject = v3Migrated; - } - - return { settings: settingsObject as Settings, rawJson: content }; + return { + settings: settingsObject as Settings, + rawJson: content, + migrationWarnings, + }; } } catch (error: unknown) { settingsErrors.push({ @@ -1070,7 +605,11 @@ export function loadSettings( ); const userResult = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User); - let workspaceResult: { settings: Settings; rawJson?: string } = { + let workspaceResult: { + settings: Settings; + rawJson?: string; + migrationWarnings?: string[]; + } = { settings: {} as Settings, rawJson: undefined, }; @@ -1140,6 +679,14 @@ export function loadSettings( ); } + // Collect all migration warnings from all scopes + const allMigrationWarnings: string[] = [ + ...(systemResult.migrationWarnings ?? []), + ...(systemDefaultsResult.migrationWarnings ?? []), + ...(userResult.migrationWarnings ?? []), + ...(workspaceResult.migrationWarnings ?? []), + ]; + return new LoadedSettings( { path: systemSettingsPath, @@ -1167,6 +714,7 @@ export function loadSettings( }, isTrusted, migratedInMemorScopes, + allMigrationWarnings, ); } @@ -1178,21 +726,14 @@ export function saveSettings(settingsFile: SettingsFile): void { fs.mkdirSync(dirPath, { recursive: true }); } - let settingsToSave = settingsFile.originalSettings; - if (!MIGRATE_V2_OVERWRITE) { - settingsToSave = migrateSettingsToV1( - settingsToSave as Record, - ) as Settings; - } - // Use the format-preserving update function updateSettingsFilePreservingFormat( settingsFile.path, - settingsToSave as Record, + settingsFile.originalSettings as Record, ); } catch (error) { - writeStderrLine('Error saving user settings file.'); - writeStderrLine(error instanceof Error ? error.message : String(error)); + debugLogger.error('Error saving user settings file.'); + debugLogger.error(error instanceof Error ? error.message : String(error)); throw error; } } diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index fc902234f..cfde449ca 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -28,7 +28,7 @@ describe('SettingsSchema', () => { 'mcp', 'security', 'advanced', - 'experimental', + 'webSearch', ]; expectedSettings.forEach((setting) => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 283baee26..22ba846e4 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -147,7 +147,7 @@ const SETTINGS_SCHEMA = { requiresRestart: true, default: {} as Record, description: - 'Environment variables to set as fallback defaults. These are loaded with the lowest priority: system environment variables > .env files > settings.env.', + 'Environment variables to set as fallback defaults. These are loaded with the lowest priority: system environment variables > .env files > settings.json env field.', showInDialog: false, mergeStrategy: MergeStrategy.SHALLOW_MERGE, }, @@ -589,7 +589,7 @@ const SETTINGS_SCHEMA = { label: 'Skip Loop Detection', category: 'Model', requiresRestart: false, - default: false, + default: true, description: 'Disable all loop detection checks (streaming and LLM).', showInDialog: false, }, @@ -1177,34 +1177,71 @@ const SETTINGS_SCHEMA = { showInDialog: false, }, - experimental: { + hooksConfig: { type: 'object', - label: 'Experimental', - category: 'Experimental', - requiresRestart: true, + label: 'Hooks Config', + category: 'Advanced', + requiresRestart: false, default: {}, - description: 'Setting to enable experimental features', + description: + 'Hook configurations for intercepting and customizing agent behavior.', showInDialog: false, properties: { - visionModelPreview: { + enabled: { type: 'boolean', - label: 'Vision Model Preview', - category: 'Experimental', - requiresRestart: false, + label: 'Enable Hooks', + category: 'Advanced', + requiresRestart: true, default: true, description: - 'Enable vision model support and auto-switching functionality. When disabled, vision models like qwen-vl-max-latest will be hidden and auto-switching will not occur.', + 'Canonical toggle for the hooks system. When disabled, no hooks will be executed.', showInDialog: false, }, - vlmSwitchMode: { - type: 'string', - label: 'VLM Switch Mode', - category: 'Experimental', + disabled: { + type: 'array', + label: 'Disabled Hooks', + category: 'Advanced', requiresRestart: false, - default: undefined as string | undefined, + default: [] as string[], description: - 'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). If not set, user will be prompted each time. This is a temporary experimental feature.', + 'List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured.', showInDialog: false, + mergeStrategy: MergeStrategy.UNION, + }, + }, + }, + + hooks: { + type: 'object', + label: 'Hooks', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: + 'Hook event configurations for extending CLI behavior at various lifecycle points.', + showInDialog: false, + properties: { + UserPromptSubmit: { + type: 'array', + label: 'Before Agent Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute before agent processing. Can modify prompts or inject context.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, + }, + Stop: { + type: 'array', + label: 'After Agent Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute after agent processing. Can post-process responses or log interactions.', + showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, }, }, }, diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts index 72e7fc1b0..03c164d8e 100644 --- a/packages/cli/src/constants/codingPlan.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -61,6 +61,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 1000000, }, }, { @@ -68,12 +69,18 @@ export function generateCodingPlanTemplate( name: '[Bailian Coding Plan] qwen3-coder-plus', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 1000000, + }, }, { id: 'qwen3-coder-next', name: '[Bailian Coding Plan] qwen3-coder-next', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 262144, + }, }, { id: 'qwen3-max-2026-01-23', @@ -84,6 +91,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 262144, }, }, { @@ -95,6 +103,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 202752, }, }, { @@ -106,6 +115,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 202752, }, }, { @@ -117,6 +127,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 1000000, }, }, { @@ -128,6 +139,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 262144, }, }, ]; @@ -144,6 +156,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 1000000, }, }, { @@ -151,12 +164,18 @@ export function generateCodingPlanTemplate( name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-plus', baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 1000000, + }, }, { id: 'qwen3-coder-next', name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-next', baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 262144, + }, }, { id: 'qwen3-max-2026-01-23', @@ -167,6 +186,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 262144, }, }, { @@ -178,6 +198,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 202752, }, }, { @@ -189,6 +210,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 202752, }, }, { @@ -200,6 +222,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 1000000, }, }, { @@ -211,6 +234,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 262144, }, }, ]; @@ -227,15 +251,9 @@ export function getCodingPlanConfig(region: CodingPlanRegion) { region === CodingPlanRegion.CHINA ? 'https://coding.dashscope.aliyuncs.com/v1' : 'https://coding-intl.dashscope.aliyuncs.com/v1'; - const regionName = - region === CodingPlanRegion.CHINA - ? 'Coding Plan (Bailian, China)' - : 'Coding Plan (Bailian, Global/Intl)'; - return { template, baseUrl, - regionName, version: computeCodingPlanVersion(template), }; } diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 44c4c29d7..e4efea1f5 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -48,6 +48,7 @@ vi.mock('./config/config.js', () => ({ getSandbox: vi.fn(() => false), getQuestion: vi.fn(() => ''), isInteractive: () => false, + getWarnings: vi.fn(() => []), } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), @@ -177,6 +178,7 @@ describe('gemini.tsx main function', () => { getGeminiMdFileCount: () => 0, getProjectRoot: () => '/', getOutputFormat: () => OutputFormat.TEXT, + getWarnings: () => [], } as unknown as Config; }); vi.mocked(loadSettings).mockReturnValue({ @@ -188,6 +190,7 @@ describe('gemini.tsx main function', () => { }, setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + migrationWarnings: [], } as never); try { await main(); @@ -260,7 +263,7 @@ describe('gemini.tsx main function', () => { 'isRaw', ); Object.defineProperty(process.stdin, 'isTTY', { - value: true, + value: false, // 在 stream-json 模式下应为 false configurable: true, }); Object.defineProperty(process.stdin, 'isRaw', { @@ -320,6 +323,7 @@ describe('gemini.tsx main function', () => { }, setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + migrationWarnings: [], } as never); vi.mocked(parseArguments).mockResolvedValue({ @@ -341,6 +345,10 @@ describe('gemini.tsx main function', () => { getProjectRoot: () => '/', getInputFormat: () => 'stream-json', getContentGeneratorConfig: () => ({ authType: 'test-auth' }), + getWarnings: () => [], + getUsageStatisticsEnabled: () => true, + getSessionId: () => 'test-session-id', + getOutputFormat: () => OutputFormat.TEXT, } as unknown as Config; vi.mocked(loadCliConfig).mockResolvedValue(configStub); @@ -438,6 +446,8 @@ describe('gemini.tsx main function kitty protocol', () => { getExperimentalZedIntegration: () => false, getScreenReader: () => false, getGeminiMdFileCount: () => 0, + getWarnings: () => [], + getUsageStatisticsEnabled: () => true, } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ errors: [], @@ -448,6 +458,7 @@ describe('gemini.tsx main function kitty protocol', () => { }, setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + migrationWarnings: [], } as never); vi.mocked(parseArguments).mockResolvedValue({ model: undefined, @@ -483,7 +494,6 @@ describe('gemini.tsx main function kitty protocol', () => { googleSearchEngineId: undefined, webSearchDefault: undefined, screenReader: undefined, - vlmSwitchMode: undefined, inputFormat: undefined, outputFormat: undefined, includePartialMessages: undefined, @@ -494,6 +504,7 @@ describe('gemini.tsx main function kitty protocol', () => { authType: undefined, maxSessionTurns: undefined, experimentalLsp: undefined, + experimentalHooks: undefined, channel: undefined, chatRecording: undefined, sessionId: undefined, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 08c0631a8..58a735c73 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -385,17 +385,16 @@ export async function main() { setMaxSizedBoxDebugging(isDebugMode); // Check input format early to determine initialization flow - const inputFormat = - typeof config.getInputFormat === 'function' + // In TTY mode, ignore stream-json input format to prevent process from hanging + const inputFormat = process.stdin.isTTY + ? InputFormat.TEXT + : typeof config.getInputFormat === 'function' ? config.getInputFormat() : InputFormat.TEXT; // For stream-json mode, defer config.initialize() until after the initialize control request // For other modes, initialize normally - let initializationResult: InitializationResult | undefined; - if (inputFormat !== InputFormat.STREAM_JSON) { - initializationResult = await initializeApp(config, settings); - } + const initializationResult = await initializeApp(config, settings); if (config.getExperimentalZedIntegration()) { return runAcpAgent(config, settings, argv); @@ -411,6 +410,7 @@ export async function main() { useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true, })), ...getSettingsWarnings(settings), + ...config.getWarnings(), ]), ]; diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 20618330d..6f27800ac 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -157,6 +157,7 @@ export default { 'Enter to confirm, Esc to cancel': 'Enter zum Bestätigen, Esc zum Abbrechen', 'Enter to select, ↑↓ to navigate, Esc to go back': 'Enter zum Auswählen, ↑↓ zum Navigieren, Esc zum Zurückgehen', + 'Enter to submit, Esc to go back': 'Enter zum Absenden, Esc zum Zurückgehen', 'Invalid step: {{step}}': 'Ungültiger Schritt: {{step}}', 'No subagents found.': 'Keine Unteragenten gefunden.', "Use '/agents create' to create your first subagent.": @@ -908,7 +909,6 @@ export default { 'missing name': 'Name fehlt', 'missing description': 'Beschreibung fehlt', '(unnamed)': '(unbenannt)', - unknown: 'unbekannt', 'Warning: This tool cannot be called by the LLM': 'Warnung: Dieses Werkzeug kann nicht vom LLM aufgerufen werden', Reason: 'Grund', @@ -974,18 +974,22 @@ export default { // Dialogs - Auth // ============================================================================ 'Get started': 'Loslegen', - 'How would you like to authenticate for this project?': - 'Wie möchten Sie sich für dieses Projekt authentifizieren?', + 'Select Authentication Method': 'Authentifizierungsmethode auswählen', 'OpenAI API key is required to use OpenAI authentication.': 'OpenAI API-Schlüssel ist für die OpenAI-Authentifizierung erforderlich.', 'You must select an auth method to proceed. Press Ctrl+C again to exit.': 'Sie müssen eine Authentifizierungsmethode wählen, um fortzufahren. Drücken Sie erneut Strg+C zum Beenden.', - '(Use Enter to Set Auth)': '(Enter zum Festlegen der Authentifizierung)', - 'Terms of Services and Privacy Notice for Qwen Code': - 'Nutzungsbedingungen und Datenschutzhinweis für Qwen Code', + 'Terms of Services and Privacy Notice': + 'Nutzungsbedingungen und Datenschutzhinweis', 'Qwen OAuth': 'Qwen OAuth', + 'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models': + 'Kostenlos \u00B7 Bis zu 1.000 Anfragen/Tag \u00B7 Qwen neueste Modelle', 'Login with QwenChat account to use daily free quota.': 'Melden Sie sich mit Ihrem QwenChat-Konto an, um das tägliche kostenlose Kontingent zu nutzen.', + 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': + 'Kostenpflichtig \u00B7 Bis zu 6.000 Anfragen/5 Std. \u00B7 Alle Alibaba Cloud Coding Plan Modelle', + 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', + 'Bring your own API key': 'Eigenen API-Schlüssel verwenden', 'API-KEY': 'API-KEY', 'Use coding plan credentials or your own api-keys/providers.': 'Verwenden Sie Coding Plan-Anmeldedaten oder Ihre eigenen API-Schlüssel/Anbieter.', @@ -1015,6 +1019,8 @@ export default { 'Warten auf Qwen OAuth-Authentifizierung...', '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.': 'Hinweis: Ihr bestehender API-Schlüssel in settings.json wird bei Verwendung von Qwen OAuth nicht gelöscht. Sie können später bei Bedarf zur OpenAI-Authentifizierung zurückwechseln.', + 'Note: Your existing API key will not be cleared when using Qwen OAuth.': + 'Hinweis: Ihr bestehender API-Schlüssel wird bei Verwendung von Qwen OAuth nicht gelöscht.', 'Authentication timed out. Please try again.': 'Authentifizierung abgelaufen. Bitte versuchen Sie es erneut.', 'Waiting for auth... (Press ESC or CTRL+C to cancel)': @@ -1064,6 +1070,17 @@ export default { '(default)': '(Standard)', '(set)': '(gesetzt)', '(not set)': '(nicht gesetzt)', + Modality: 'Modalität', + 'Context Window': 'Kontextfenster', + text: 'Text', + 'text-only': 'nur Text', + image: 'Bild', + pdf: 'PDF', + audio: 'Audio', + video: 'Video', + 'not set': 'nicht gesetzt', + none: 'keine', + unknown: 'unbekannt', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "Modell konnte nicht auf '{{modelId}}' umgestellt werden.\n\n{{error}}", 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': @@ -1410,38 +1427,43 @@ export default { 'Erweiterungsseite wird im Browser geöffnet: {{url}}', 'Failed to open browser. Check out the extensions gallery at {{url}}': 'Browser konnte nicht geöffnet werden. Besuchen Sie die Erweiterungsgalerie unter {{url}}', + 'Use /compress when the conversation gets long to summarize history and free up context.': + 'Verwenden Sie /compress, wenn die Unterhaltung lang wird, um den Verlauf zusammenzufassen und Kontext freizugeben.', + 'Start a fresh idea with /clear or /new; the previous session stays available in history.': + 'Starten Sie eine neue Idee mit /clear oder /new; die vorherige Sitzung bleibt im Verlauf verfügbar.', + 'Use /bug to submit issues to the maintainers when something goes off.': + 'Verwenden Sie /bug, um Probleme an die Betreuer zu melden, wenn etwas schiefgeht.', + 'Switch auth type quickly with /auth.': + 'Wechseln Sie den Authentifizierungstyp schnell mit /auth.', + 'You can run any shell commands from Qwen Code using ! (e.g. !ls).': + 'Sie können beliebige Shell-Befehle in Qwen Code mit ! ausführen (z. B. !ls).', + 'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.': + 'Geben Sie / ein, um das Befehlsmenü zu öffnen; Tab vervollständigt Slash-Befehle und gespeicherte Prompts.', + 'You can resume a previous conversation by running qwen --continue or qwen --resume.': + 'Sie können eine frühere Unterhaltung mit qwen --continue oder qwen --resume fortsetzen.', 'You can switch permission mode quickly with Shift+Tab or /approval-mode.': 'Sie können den Berechtigungsmodus schnell mit Shift+Tab oder /approval-mode wechseln.', 'You can switch permission mode quickly with Tab or /approval-mode.': 'Sie können den Berechtigungsmodus schnell mit Tab oder /approval-mode wechseln.', + 'Try /insight to generate personalized insights from your chat history.': + 'Probieren Sie /insight, um personalisierte Erkenntnisse aus Ihrem Chatverlauf zu erstellen.', // ============================================================================ - // Custom API-KEY Configuration + // Custom API Key Configuration // ============================================================================ - 'For advanced users who want to configure models manually.': - 'Für fortgeschrittene Benutzer, die Modelle manuell konfigurieren möchten.', - 'Please configure your models in settings.json:': - 'Bitte konfigurieren Sie Ihre Modelle in settings.json:', - 'Set API key via environment variable (e.g., OPENAI_API_KEY)': - 'API-Schlüssel über Umgebungsvariable setzen (z.B. OPENAI_API_KEY)', - "Add model configuration to modelProviders['openai'] (or other auth types)": - "Modellkonfiguration zu modelProviders['openai'] (oder anderen Authentifizierungstypen) hinzufügen", - 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': - 'Jeder Anbieter benötigt: id, envKey (erforderlich), plus optionale baseUrl, generationConfig', - 'Use /model command to select your preferred model from the configured list': - 'Verwenden Sie den /model-Befehl, um Ihr bevorzugtes Modell aus der konfigurierten Liste auszuwählen', - 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': - 'Unterstützte Authentifizierungstypen: openai, anthropic, gemini, vertex-ai, usw.', + 'You can configure your API key and models in settings.json': + 'Sie können Ihren API-Schlüssel und Modelle in settings.json konfigurieren', + 'Refer to the documentation for setup instructions': + 'Einrichtungsanweisungen finden Sie in der Dokumentation', // ============================================================================ // Coding Plan Authentication // ============================================================================ - 'Please enter your API key:': 'Bitte geben Sie Ihren API-Schlüssel ein:', 'API key cannot be empty.': 'API-Schlüssel darf nicht leer sein.', - 'You can get your exclusive Coding Plan API-KEY here:': - 'Hier können Sie Ihren exklusiven Coding Plan API-KEY erhalten:', - 'New model configurations are available for Bailian Coding Plan. Update now?': - 'Neue Modellkonfigurationen sind für Bailian Coding Plan verfügbar. Jetzt aktualisieren?', + 'You can get your Coding Plan API key here': + 'Sie können Ihren Coding-Plan-API-Schlüssel hier erhalten', + 'New model configurations are available for Alibaba Cloud Coding Plan. Update now?': + 'Neue Modellkonfigurationen sind für Alibaba Cloud Coding Plan verfügbar. Jetzt aktualisieren?', 'Coding Plan configuration updated successfully. New models are now available.': 'Coding Plan-Konfiguration erfolgreich aktualisiert. Neue Modelle sind jetzt verfügbar.', 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': @@ -1452,34 +1474,18 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ - 'Coding Plan': 'Coding Plan', - 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', - 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', - "Paste your api key of Bailian Coding Plan and you're all set!": - 'Fügen Sie Ihren Bailian Coding Plan API-Schlüssel ein und Sie sind bereit!', - "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": - 'Fügen Sie Ihren Coding Plan (Bailian, Global/Intl) API-Schlüssel ein und Sie sind bereit!', - Custom: 'Benutzerdefiniert', - 'More instructions about configuring `modelProviders` manually.': - 'Weitere Anweisungen zur manuellen Konfiguration von `modelProviders`.', - 'Select API-KEY configuration mode:': - 'API-KEY-Konfigurationsmodus auswählen:', - '(Press Escape to go back)': '(Escape drücken zum Zurückgehen)', - '(Press Enter to submit, Escape to cancel)': - '(Enter zum Absenden, Escape zum Abbrechen)', - 'More instructions please check:': 'Weitere Anweisungen finden Sie unter:', + 'Select Region for Coding Plan': 'Region für Coding Plan auswählen', + 'Choose based on where your account is registered': + 'Wählen Sie basierend auf dem Registrierungsort Ihres Kontos', + 'Enter Coding Plan API Key': 'Coding-Plan-API-Schlüssel eingeben', // ============================================================================ // Coding Plan International Updates // ============================================================================ 'New model configurations are available for {{region}}. Update now?': 'Neue Modellkonfigurationen sind für {{region}} verfügbar. Jetzt aktualisieren?', - 'New model configurations are available for Bailian Coding Plan (China). Update now?': - 'Neue Modellkonfigurationen sind für Bailian Coding Plan (China) verfügbar. Jetzt aktualisieren?', - 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': - 'Neue Modellkonfigurationen sind für Coding Plan (Bailian, Global/Intl) verfügbar. Jetzt aktualisieren?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.', - 'Authenticated successfully with {{region}}. API key is stored in settings.env.': - 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel ist in settings.env gespeichert.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert (gesichert).', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 50538af92..31e2b53e8 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -178,6 +178,7 @@ export default { 'Enter to confirm, Esc to cancel': 'Enter to confirm, Esc to cancel', 'Enter to select, ↑↓ to navigate, Esc to go back': 'Enter to select, ↑↓ to navigate, Esc to go back', + 'Enter to submit, Esc to go back': 'Enter to submit, Esc to go back', 'Invalid step: {{step}}': 'Invalid step: {{step}}', 'No subagents found.': 'No subagents found.', "Use '/agents create' to create your first subagent.": @@ -792,7 +793,6 @@ export default { 'missing name': 'missing name', 'missing description': 'missing description', '(unnamed)': '(unnamed)', - unknown: 'unknown', 'Warning: This tool cannot be called by the LLM': 'Warning: This tool cannot be called by the LLM', Reason: 'Reason', @@ -994,18 +994,22 @@ export default { // Dialogs - Auth // ============================================================================ 'Get started': 'Get started', - 'How would you like to authenticate for this project?': - 'How would you like to authenticate for this project?', + 'Select Authentication Method': 'Select Authentication Method', 'OpenAI API key is required to use OpenAI authentication.': 'OpenAI API key is required to use OpenAI authentication.', 'You must select an auth method to proceed. Press Ctrl+C again to exit.': 'You must select an auth method to proceed. Press Ctrl+C again to exit.', - '(Use Enter to Set Auth)': '(Use Enter to Set Auth)', - 'Terms of Services and Privacy Notice for Qwen Code': - 'Terms of Services and Privacy Notice for Qwen Code', + 'Terms of Services and Privacy Notice': + 'Terms of Services and Privacy Notice', 'Qwen OAuth': 'Qwen OAuth', + 'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models': + 'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models', 'Login with QwenChat account to use daily free quota.': 'Login with QwenChat account to use daily free quota.', + 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': + 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models', + 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', + 'Bring your own API key': 'Bring your own API key', 'API-KEY': 'API-KEY', 'Use coding plan credentials or your own api-keys/providers.': 'Use coding plan credentials or your own api-keys/providers.', @@ -1033,6 +1037,8 @@ export default { 'Waiting for Qwen OAuth authentication...', '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.': '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.', + 'Note: Your existing API key will not be cleared when using Qwen OAuth.': + 'Note: Your existing API key will not be cleared when using Qwen OAuth.', 'Authentication timed out. Please try again.': 'Authentication timed out. Please try again.', 'Waiting for auth... (Press ESC or CTRL+C to cancel)': @@ -1080,6 +1086,17 @@ export default { '(default)': '(default)', '(set)': '(set)', '(not set)': '(not set)', + Modality: 'Modality', + 'Context Window': 'Context Window', + text: 'text', + 'text-only': 'text-only', + image: 'image', + pdf: 'pdf', + audio: 'audio', + video: 'video', + 'not set': 'not set', + none: 'none', + unknown: 'unknown', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "Failed to switch model to '{{modelId}}'.\n\n{{error}}", 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': @@ -1174,6 +1191,8 @@ export default { 'You can switch permission mode quickly with Shift+Tab or /approval-mode.', 'You can switch permission mode quickly with Tab or /approval-mode.': 'You can switch permission mode quickly with Tab or /approval-mode.', + 'Try /insight to generate personalized insights from your chat history.': + 'Try /insight to generate personalized insights from your chat history.', // ============================================================================ // Exit Screen / Stats @@ -1441,18 +1460,20 @@ export default { 'Rate limit error: {{reason}}': 'Rate limit error: {{reason}}', 'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})': 'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})', + 'Press Ctrl+Y to retry': 'Press Ctrl+Y to retry', + 'No failed request to retry.': 'No failed request to retry.', + 'to retry last request': 'to retry last request', // ============================================================================ // Coding Plan Authentication // ============================================================================ - 'Please enter your API key:': 'Please enter your API key:', 'API key cannot be empty.': 'API key cannot be empty.', - 'You can get your exclusive Coding Plan API-KEY here:': - 'You can get your exclusive Coding Plan API-KEY here:', + 'You can get your Coding Plan API key here': + 'You can get your Coding Plan API key here', 'API key is stored in settings.env. You can migrate it to a .env file for better security.': 'API key is stored in settings.env. You can migrate it to a .env file for better security.', - 'New model configurations are available for Bailian Coding Plan. Update now?': - 'New model configurations are available for Bailian Coding Plan. Update now?', + 'New model configurations are available for Alibaba Cloud Coding Plan. Update now?': + 'New model configurations are available for Alibaba Cloud Coding Plan. Update now?', 'Coding Plan configuration updated successfully. New models are now available.': 'Coding Plan configuration updated successfully. New models are now available.', 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': @@ -1461,53 +1482,28 @@ export default { 'Failed to update Coding Plan configuration: {{message}}', // ============================================================================ - // Custom API-KEY Configuration + // Custom API Key Configuration // ============================================================================ - 'For advanced users who want to configure models manually.': - 'For advanced users who want to configure models manually.', - 'Please configure your models in settings.json:': - 'Please configure your models in settings.json:', - 'Set API key via environment variable (e.g., OPENAI_API_KEY)': - 'Set API key via environment variable (e.g., OPENAI_API_KEY)', - "Add model configuration to modelProviders['openai'] (or other auth types)": - "Add model configuration to modelProviders['openai'] (or other auth types)", - 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': - 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig', - 'Use /model command to select your preferred model from the configured list': - 'Use /model command to select your preferred model from the configured list', - 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': - 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.', - 'More instructions please check:': 'More instructions please check:', + 'You can configure your API key and models in settings.json': + 'You can configure your API key and models in settings.json', + 'Refer to the documentation for setup instructions': + 'Refer to the documentation for setup instructions', // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ - 'Coding Plan': 'Coding Plan', - 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', - 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', - "Paste your api key of Bailian Coding Plan and you're all set!": - "Paste your api key of Bailian Coding Plan and you're all set!", - "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": - "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!", - Custom: 'Custom', - 'More instructions about configuring `modelProviders` manually.': - 'More instructions about configuring `modelProviders` manually.', - 'Select API-KEY configuration mode:': 'Select API-KEY configuration mode:', - '(Press Escape to go back)': '(Press Escape to go back)', - '(Press Enter to submit, Escape to cancel)': - '(Press Enter to submit, Escape to cancel)', + 'Select Region for Coding Plan': 'Select Region for Coding Plan', + 'Choose based on where your account is registered': + 'Choose based on where your account is registered', + 'Enter Coding Plan API Key': 'Enter Coding Plan API Key', // ============================================================================ // Coding Plan International Updates // ============================================================================ 'New model configurations are available for {{region}}. Update now?': 'New model configurations are available for {{region}}. Update now?', - 'New model configurations are available for Bailian Coding Plan (China). Update now?': - 'New model configurations are available for Bailian Coding Plan (China). Update now?', - 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': - 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}} configuration updated successfully. Model switched to "{{model}}".', - 'Authenticated successfully with {{region}}. API key is stored in settings.env.': - 'Authenticated successfully with {{region}}. API key is stored in settings.env.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index dd95a3ca9..126670a1d 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -142,6 +142,7 @@ export default { 'Enter to confirm, Esc to cancel': 'Enter で確定、Esc でキャンセル', 'Enter to select, ↑↓ to navigate, Esc to go back': 'Enter で選択、↑↓ で移動、Esc で戻る', + 'Enter to submit, Esc to go back': 'Enter で送信、Esc で戻る', 'Invalid step: {{step}}': '無効なステップ: {{step}}', 'No subagents found.': 'サブエージェントが見つかりません', "Use '/agents create' to create your first subagent.": @@ -648,7 +649,6 @@ export default { 'missing name': '名前なし', 'missing description': '説明なし', '(unnamed)': '(名前なし)', - unknown: '不明', 'Warning: This tool cannot be called by the LLM': '警告: このツールはLLMによって呼び出すことができません', Reason: '理由', @@ -701,18 +701,21 @@ export default { '🎯 Overall Goal:': '🎯 全体目標:', // Dialogs - Auth 'Get started': '始める', - 'How would you like to authenticate for this project?': - 'このプロジェクトの認証方法を選択してください:', + 'Select Authentication Method': '認証方法を選択', 'OpenAI API key is required to use OpenAI authentication.': 'OpenAI認証を使用するには OpenAI APIキーが必要です', '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 の利用規約とプライバシー通知', + 'Terms of Services and Privacy Notice': '利用規約とプライバシー通知', 'Qwen OAuth': 'Qwen OAuth', + 'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models': + '無料 \u00B7 1日最大1,000リクエスト \u00B7 Qwen最新モデル', 'Login with QwenChat account to use daily free quota.': 'QwenChatアカウントでログインして、毎日の無料クォータをご利用ください。', + 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': + '有料 \u00B7 5時間最大6,000リクエスト \u00B7 すべての Alibaba Cloud Coding Plan モデル', + 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', + 'Bring your own API key': '自分のAPIキーを使用', 'API-KEY': 'API-KEY', 'Use coding plan credentials or your own api-keys/providers.': 'Coding Planの認証情報またはご自身のAPIキー/プロバイダーをご利用ください。', @@ -740,6 +743,8 @@ export default { '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.': '注: Qwen OAuthを使用しても、settings.json内の既存のAPIキーはクリアされません。必要に応じて後でOpenAI認証に切り替えることができます', + 'Note: Your existing API key will not be cleared when using Qwen OAuth.': + '注: Qwen OAuthを使用しても、既存のAPIキーはクリアされません。', 'Authentication timed out. Please try again.': '認証がタイムアウトしました。再度お試しください', 'Waiting for auth... (Press ESC or CTRL+C to cancel)': @@ -761,6 +766,17 @@ export default { // Dialogs - Model 'Select Model': 'モデルを選択', '(Press Esc to close)': '(Esc で閉じる)', + Modality: 'モダリティ', + 'Context Window': 'コンテキストウィンドウ', + text: 'テキスト', + 'text-only': 'テキストのみ', + image: '画像', + pdf: 'PDF', + audio: '音声', + video: '動画', + 'not set': '未設定', + none: 'なし', + unknown: '不明', 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': 'Qwen 3.5 Plus — 効率的なハイブリッドモデル、業界トップクラスのコーディング性能', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': @@ -813,6 +829,27 @@ export default { "Starting OAuth authentication for MCP server '{{name}}'...": "MCPサーバー '{{name}}' のOAuth認証を開始中...", // Startup Tips + 'Tips:': 'ヒント:', + 'Use /compress when the conversation gets long to summarize history and free up context.': + '会話が長くなったら /compress で履歴を要約し、コンテキストを解放できます。', + 'Start a fresh idea with /clear or /new; the previous session stays available in history.': + '/clear または /new で新しいアイデアを始められます。前のセッションは履歴に残ります。', + 'Use /bug to submit issues to the maintainers when something goes off.': + '問題が発生したら /bug でメンテナーに報告できます。', + 'Switch auth type quickly with /auth.': + '/auth で認証タイプをすばやく切り替えられます。', + 'You can run any shell commands from Qwen Code using ! (e.g. !ls).': + 'Qwen Code から ! を使って任意のシェルコマンドを実行できます(例: !ls)。', + 'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.': + '/ を入力してコマンドポップアップを開きます。Tab でスラッシュコマンドと保存済みプロンプトを補完できます。', + 'You can resume a previous conversation by running qwen --continue or qwen --resume.': + 'qwen --continue または qwen --resume で前の会話を再開できます。', + 'You can switch permission mode quickly with Shift+Tab or /approval-mode.': + 'Shift+Tab または /approval-mode で権限モードをすばやく切り替えられます。', + 'You can switch permission mode quickly with Tab or /approval-mode.': + 'Tab または /approval-mode で権限モードをすばやく切り替えられます。', + 'Try /insight to generate personalized insights from your chat history.': + '/insight でチャット履歴からパーソナライズされたインサイトを生成できます。', 'Tips for getting started:': '始めるためのヒント:', '1. Ask questions, edit files, or run commands.': '1. 質問したり、ファイルを編集したり、コマンドを実行したりできます', @@ -921,32 +958,19 @@ export default { ], // ============================================================================ - // Custom API-KEY Configuration + // Custom API Key Configuration // ============================================================================ - 'For advanced users who want to configure models manually.': - 'モデルを手動で設定したい上級ユーザー向け。', - 'Please configure your models in settings.json:': - 'settings.json でモデルを設定してください:', - 'Set API key via environment variable (e.g., OPENAI_API_KEY)': - '環境変数を使用して API キーを設定してください(例:OPENAI_API_KEY)', - "Add model configuration to modelProviders['openai'] (or other auth types)": - "modelProviders['openai'](または他の認証タイプ)にモデル設定を追加してください", - 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': - '各プロバイダーには:id、envKey(必須)、およびオプションの baseUrl、generationConfig が必要です', - 'Use /model command to select your preferred model from the configured list': - '/model コマンドを使用して、設定済みリストからお好みのモデルを選択してください', - 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': - 'サポートされている認証タイプ:openai、anthropic、gemini、vertex-ai など', + 'You can configure your API key and models in settings.json': + 'settings.json で API キーとモデルを設定できます', + 'Refer to the documentation for setup instructions': + 'セットアップ手順はドキュメントを参照してください', // ============================================================================ // Coding Plan Authentication // ============================================================================ - 'Please enter your API key:': 'APIキーを入力してください:', 'API key cannot be empty.': 'APIキーは空にできません。', - 'You can get your exclusive Coding Plan API-KEY here:': - 'Coding Plan の API-KEY はこちらで取得できます:', - 'New model configurations are available for Bailian Coding Plan. Update now?': - 'Bailian Coding Plan の新しいモデル設定が利用可能です。今すぐ更新しますか?', + 'You can get your Coding Plan API key here': + 'Coding Plan APIキーはこちらで取得できます', 'Coding Plan configuration updated successfully. New models are now available.': 'Coding Plan の設定が正常に更新されました。新しいモデルが利用可能になりました。', 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': @@ -957,34 +981,18 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ - 'Coding Plan': 'Coding Plan', - 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, 中国)', - 'Coding Plan (Bailian, Global/Intl)': - 'Coding Plan (Bailian, グローバル/国際)', - "Paste your api key of Bailian Coding Plan and you're all set!": - 'Bailian Coding PlanのAPIキーを貼り付けるだけで準備完了です!', - "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": - 'Coding Plan (Bailian, グローバル/国際) のAPIキーを貼り付けるだけで準備完了です!', - Custom: 'カスタム', - 'More instructions about configuring `modelProviders` manually.': - '`modelProviders`を手動で設定する方法の詳細はこちら。', - 'Select API-KEY configuration mode:': 'API-KEY設定モードを選択してください:', - '(Press Escape to go back)': '(Escapeキーで戻る)', - '(Press Enter to submit, Escape to cancel)': - '(Enterで送信、Escapeでキャンセル)', - 'More instructions please check:': '詳細な手順はこちらをご確認ください:', + 'Select Region for Coding Plan': 'Coding Planのリージョンを選択', + 'Choose based on where your account is registered': + 'アカウントの登録先に応じて選択してください', + 'Enter Coding Plan API Key': 'Coding Plan APIキーを入力', // ============================================================================ // Coding Plan International Updates // ============================================================================ 'New model configurations are available for {{region}}. Update now?': '{{region}} の新しいモデル設定が利用可能です。今すぐ更新しますか?', - 'New model configurations are available for Bailian Coding Plan (China). Update now?': - 'Bailian Coding Plan (中国) の新しいモデル設定が利用可能です。今すぐ更新しますか?', - 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': - 'Coding Plan (Bailian, グローバル/国際) の新しいモデル設定が利用可能です。今すぐ更新しますか?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。', - 'Authenticated successfully with {{region}}. API key is stored in settings.env.': - '{{region}} での認証に成功しました。APIキーは settings.env に保存されています。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + '{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました(バックアップ済み)。', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index b89472621..cee7534aa 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -173,6 +173,7 @@ export default { 'Enter to confirm, Esc to cancel': 'Enter para confirmar, Esc para cancelar', 'Enter to select, ↑↓ to navigate, Esc to go back': 'Enter para selecionar, ↑↓ para navegar, Esc para voltar', + 'Enter to submit, Esc to go back': 'Enter para enviar, Esc para voltar', 'Invalid step: {{step}}': 'Etapa inválida: {{step}}', 'No subagents found.': 'Nenhum subagente encontrado.', "Use '/agents create' to create your first subagent.": @@ -914,7 +915,6 @@ export default { 'missing name': 'nome ausente', 'missing description': 'descrição ausente', '(unnamed)': '(sem nome)', - unknown: 'desconhecido', 'Warning: This tool cannot be called by the LLM': 'Aviso: Esta ferramenta não pode ser chamada pelo LLM', Reason: 'Motivo', @@ -980,18 +980,22 @@ export default { // Dialogs - Auth // ============================================================================ 'Get started': 'Começar', - 'How would you like to authenticate for this project?': - 'Como você gostaria de se autenticar para este projeto?', + 'Select Authentication Method': 'Selecionar Método de Autenticação', 'OpenAI API key is required to use OpenAI authentication.': 'A chave da API do OpenAI é necessária para usar a autenticação do OpenAI.', 'You must select an auth method to proceed. Press Ctrl+C again to exit.': 'Você deve selecionar um método de autenticação para prosseguir. Pressione Ctrl+C novamente para sair.', - '(Use Enter to Set Auth)': '(Use Enter para Definir Autenticação)', - 'Terms of Services and Privacy Notice for Qwen Code': - 'Termos de Serviço e Aviso de Privacidade do Qwen Code', + 'Terms of Services and Privacy Notice': + 'Termos de Serviço e Aviso de Privacidade', 'Qwen OAuth': 'Qwen OAuth', + 'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models': + 'Gratuito \u00B7 Até 1.000 solicitações/dia \u00B7 Modelos Qwen mais recentes', 'Login with QwenChat account to use daily free quota.': 'Faça login com sua conta QwenChat para usar a cota gratuita diária.', + 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': + 'Pago \u00B7 Até 6.000 solicitações/5 hrs \u00B7 Todos os modelos Alibaba Cloud Coding Plan', + 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', + 'Bring your own API key': 'Traga sua própria chave API', 'API-KEY': 'API-KEY', 'Use coding plan credentials or your own api-keys/providers.': 'Use credenciais do Coding Plan ou suas próprias chaves API/provedores.', @@ -1019,6 +1023,8 @@ export default { 'Aguardando autenticação 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.': 'Nota: Sua chave de API existente no settings.json não será limpa ao usar o Qwen OAuth. Você pode voltar para a autenticação do OpenAI mais tarde, se necessário.', + 'Note: Your existing API key will not be cleared when using Qwen OAuth.': + 'Nota: Sua chave de API existente não será limpa ao usar o Qwen OAuth.', 'Authentication timed out. Please try again.': 'A autenticação expirou. Tente novamente.', 'Waiting for auth... (Press ESC or CTRL+C to cancel)': @@ -1067,6 +1073,17 @@ export default { '(default)': '(padrão)', '(set)': '(definido)', '(not set)': '(não definido)', + Modality: 'Modalidade', + 'Context Window': 'Janela de Contexto', + text: 'texto', + 'text-only': 'somente texto', + image: 'imagem', + pdf: 'PDF', + audio: 'áudio', + video: 'vídeo', + 'not set': 'não definido', + none: 'nenhum', + unknown: 'desconhecido', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "Falha ao trocar o modelo para '{{modelId}}'.\n\n{{error}}", 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': @@ -1162,6 +1179,8 @@ export default { 'Você pode retomar uma conversa anterior executando qwen --continue ou qwen --resume.', 'You can switch permission mode quickly with Shift+Tab or /approval-mode.': 'Você pode alternar o modo de permissão rapidamente com Shift+Tab ou /approval-mode.', + 'Try /insight to generate personalized insights from your chat history.': + 'Experimente /insight para gerar insights personalizados do seu histórico de conversas.', // ============================================================================ // Exit Screen / Stats @@ -1424,32 +1443,21 @@ export default { 'Falha ao abrir o navegador. Confira a galeria de extensões em {{url}}', // ============================================================================ - // Custom API-KEY Configuration + // Custom API Key Configuration // ============================================================================ - 'For advanced users who want to configure models manually.': - 'Para usuários avançados que desejam configurar modelos manualmente.', - 'Please configure your models in settings.json:': - 'Por favor, configure seus modelos em settings.json:', - 'Set API key via environment variable (e.g., OPENAI_API_KEY)': - 'Defina a chave de API via variável de ambiente (ex: OPENAI_API_KEY)', - "Add model configuration to modelProviders['openai'] (or other auth types)": - "Adicione a configuração do modelo a modelProviders['openai'] (ou outros tipos de autenticação)", - 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': - 'Cada provedor precisa de: id, envKey (obrigatório), além de baseUrl e generationConfig opcionais', - 'Use /model command to select your preferred model from the configured list': - 'Use o comando /model para selecionar seu modelo preferido da lista configurada', - 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': - 'Tipos de autenticação suportados: openai, anthropic, gemini, vertex-ai, etc.', + 'You can configure your API key and models in settings.json': + 'Você pode configurar sua chave de API e modelos em settings.json', + 'Refer to the documentation for setup instructions': + 'Consulte a documentação para instruções de configuração', // ============================================================================ // Coding Plan Authentication // ============================================================================ - 'Please enter your API key:': 'Por favor, digite sua chave de API:', 'API key cannot be empty.': 'A chave de API não pode estar vazia.', - 'You can get your exclusive Coding Plan API-KEY here:': - 'Você pode obter sua chave de API exclusiva do Coding Plan aqui:', - 'New model configurations are available for Bailian Coding Plan. Update now?': - 'Novas configurações de modelo estão disponíveis para o Bailian Coding Plan. Atualizar agora?', + 'You can get your Coding Plan API key here': + 'Você pode obter sua chave de API do Coding Plan aqui', + 'New model configurations are available for Alibaba Cloud Coding Plan. Update now?': + 'Novas configurações de modelo estão disponíveis para o Alibaba Cloud Coding Plan. Atualizar agora?', 'Coding Plan configuration updated successfully. New models are now available.': 'Configuração do Coding Plan atualizada com sucesso. Novos modelos agora estão disponíveis.', 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': @@ -1460,34 +1468,18 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ - 'Coding Plan': 'Coding Plan', - 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', - 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', - "Paste your api key of Bailian Coding Plan and you're all set!": - 'Cole sua chave de API do Bailian Coding Plan e pronto!', - "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": - 'Cole sua chave de API do Coding Plan (Bailian, Global/Intl) e pronto!', - Custom: 'Personalizado', - 'More instructions about configuring `modelProviders` manually.': - 'Mais instruções sobre como configurar `modelProviders` manualmente.', - 'Select API-KEY configuration mode:': - 'Selecione o modo de configuração da API-KEY:', - '(Press Escape to go back)': '(Pressione Escape para voltar)', - '(Press Enter to submit, Escape to cancel)': - '(Pressione Enter para enviar, Escape para cancelar)', - 'More instructions please check:': 'Mais instruções, consulte:', + 'Select Region for Coding Plan': 'Selecionar região do Coding Plan', + 'Choose based on where your account is registered': + 'Escolha com base em onde sua conta está registrada', + 'Enter Coding Plan API Key': 'Inserir chave de API do Coding Plan', // ============================================================================ // Coding Plan International Updates // ============================================================================ 'New model configurations are available for {{region}}. Update now?': 'Novas configurações de modelo estão disponíveis para o {{region}}. Atualizar agora?', - 'New model configurations are available for Bailian Coding Plan (China). Update now?': - 'Novas configurações de modelo estão disponíveis para o Bailian Coding Plan (China). Atualizar agora?', - 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': - 'Novas configurações de modelo estão disponíveis para o Coding Plan (Bailian, Global/Intl). Atualizar agora?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': 'Configuração do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".', - 'Authenticated successfully with {{region}}. API key is stored in settings.env.': - 'Autenticado com sucesso com {{region}}. A chave de API está armazenada em settings.env.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json (com backup).', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 97ccfcb81..b7779c3ad 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -181,6 +181,7 @@ export default { 'Enter to confirm, Esc to cancel': 'Enter для подтверждения, Esc для отмены', 'Enter to select, ↑↓ to navigate, Esc to go back': 'Enter для выбора, ↑↓ для навигации, Esc для возврата', + 'Enter to submit, Esc to go back': 'Enter для отправки, Esc для возврата', 'Invalid step: {{step}}': 'Неверный шаг: {{step}}', 'No subagents found.': 'Подагенты не найдены.', "Use '/agents create' to create your first subagent.": @@ -915,7 +916,6 @@ export default { 'missing name': 'отсутствует имя', 'missing description': 'отсутствует описание', '(unnamed)': '(без имени)', - unknown: 'неизвестно', 'Warning: This tool cannot be called by the LLM': 'Предупреждение: Этот инструмент не может быть вызван LLM', Reason: 'Причина', @@ -980,18 +980,22 @@ export default { // Диалоги - Авторизация // ============================================================================ 'Get started': 'Начать', - 'How would you like to authenticate for this project?': - 'Как вы хотите авторизоваться для этого проекта?', + 'Select Authentication Method': 'Выберите метод авторизации', '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', + 'Terms of Services and Privacy Notice': + 'Условия обслуживания и уведомление о конфиденциальности', 'Qwen OAuth': 'Qwen OAuth', + 'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models': + 'Бесплатно \u00B7 До 1 000 запросов/день \u00B7 Новейшие модели Qwen', 'Login with QwenChat account to use daily free quota.': 'Войдите с помощью аккаунта QwenChat, чтобы использовать ежедневную бесплатную квоту.', + 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': + 'Платно \u00B7 До 6 000 запросов/5 часов \u00B7 Все модели Alibaba Cloud Coding Plan', + 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', + 'Bring your own API key': 'Используйте свой API-ключ', 'API-KEY': 'API-KEY', 'Use coding plan credentials or your own api-keys/providers.': 'Используйте учетные данные Coding Plan или свои собственные API-ключи/провайдеры.', @@ -1019,6 +1023,8 @@ export default { 'Ожидание авторизации 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 позже при необходимости.', + 'Note: Your existing API key will not be cleared when using Qwen OAuth.': + 'Примечание: Ваш существующий ключ API не будет удален при использовании Qwen OAuth.', 'Authentication timed out. Please try again.': 'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.', 'Waiting for auth... (Press ESC or CTRL+C to cancel)': @@ -1066,6 +1072,17 @@ export default { '(default)': '(по умолчанию)', '(set)': '(установлено)', '(not set)': '(не задано)', + Modality: 'Модальность', + 'Context Window': 'Контекстное окно', + text: 'текст', + 'text-only': 'только текст', + image: 'изображение', + pdf: 'PDF', + audio: 'аудио', + video: 'видео', + 'not set': 'не задано', + none: 'нет', + unknown: 'неизвестно', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "Не удалось переключиться на модель '{{modelId}}'.\n\n{{error}}", 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': @@ -1414,38 +1431,43 @@ export default { 'Открываем страницу расширений в браузере: {{url}}', 'Failed to open browser. Check out the extensions gallery at {{url}}': 'Не удалось открыть браузер. Посетите галерею расширений по адресу {{url}}', + 'Use /compress when the conversation gets long to summarize history and free up context.': + 'Используйте /compress, когда разговор становится длинным, чтобы подвести итог и освободить контекст.', + 'Start a fresh idea with /clear or /new; the previous session stays available in history.': + 'Начните новую идею с /clear или /new; предыдущая сессия останется в истории.', + 'Use /bug to submit issues to the maintainers when something goes off.': + 'Используйте /bug, чтобы сообщить о проблемах разработчикам.', + 'Switch auth type quickly with /auth.': + 'Быстро переключите тип аутентификации с помощью /auth.', + 'You can run any shell commands from Qwen Code using ! (e.g. !ls).': + 'Вы можете выполнять любые shell-команды в Qwen Code с помощью ! (например, !ls).', + 'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.': + 'Введите /, чтобы открыть меню команд; Tab автодополняет слэш-команды и сохранённые промпты.', + 'You can resume a previous conversation by running qwen --continue or qwen --resume.': + 'Вы можете продолжить предыдущий разговор, запустив qwen --continue или qwen --resume.', 'You can switch permission mode quickly with Shift+Tab or /approval-mode.': 'Вы можете быстро переключать режим разрешений с помощью Shift+Tab или /approval-mode.', 'You can switch permission mode quickly with Tab or /approval-mode.': 'Вы можете быстро переключать режим разрешений с помощью Tab или /approval-mode.', + 'Try /insight to generate personalized insights from your chat history.': + 'Попробуйте /insight, чтобы получить персонализированные выводы из истории чатов.', // ============================================================================ - // Custom API-KEY Configuration + // Custom API Key Configuration // ============================================================================ - 'For advanced users who want to configure models manually.': - 'Для продвинутых пользователей, которые хотят настраивать модели вручную.', - 'Please configure your models in settings.json:': - 'Пожалуйста, настройте ваши модели в settings.json:', - 'Set API key via environment variable (e.g., OPENAI_API_KEY)': - 'Установите ключ API через переменную окружения (например, OPENAI_API_KEY)', - "Add model configuration to modelProviders['openai'] (or other auth types)": - "Добавьте конфигурацию модели в modelProviders['openai'] (или другие типы аутентификации)", - 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': - 'Каждому провайдеру нужны: id, envKey (обязательно), а также опциональные baseUrl, generationConfig', - 'Use /model command to select your preferred model from the configured list': - 'Используйте команду /model, чтобы выбрать предпочитаемую модель из настроенного списка', - 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': - 'Поддерживаемые типы аутентификации: openai, anthropic, gemini, vertex-ai и др.', + 'You can configure your API key and models in settings.json': + 'Вы можете настроить API-ключ и модели в settings.json', + 'Refer to the documentation for setup instructions': + 'Инструкции по настройке см. в документации', // ============================================================================ // Coding Plan Authentication // ============================================================================ - 'Please enter your API key:': 'Пожалуйста, введите ваш API-ключ:', 'API key cannot be empty.': 'API-ключ не может быть пустым.', - 'You can get your exclusive Coding Plan API-KEY here:': - 'Получите свой эксклюзивный API-KEY Coding Plan здесь:', - 'New model configurations are available for Bailian Coding Plan. Update now?': - 'Доступны новые конфигурации моделей для Bailian Coding Plan. Обновить сейчас?', + 'You can get your Coding Plan API key here': + 'Вы можете получить API-ключ Coding Plan здесь', + 'New model configurations are available for Alibaba Cloud Coding Plan. Update now?': + 'Доступны новые конфигурации моделей для Alibaba Cloud Coding Plan. Обновить сейчас?', 'Coding Plan configuration updated successfully. New models are now available.': 'Конфигурация Coding Plan успешно обновлена. Новые модели теперь доступны.', 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': @@ -1456,34 +1478,18 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ - 'Coding Plan': 'Coding Plan', - 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, Китай)', - 'Coding Plan (Bailian, Global/Intl)': - 'Coding Plan (Bailian, Глобальный/Международный)', - "Paste your api key of Bailian Coding Plan and you're all set!": - 'Вставьте ваш API-ключ Bailian Coding Plan и всё готово!', - "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": - 'Вставьте ваш API-ключ Coding Plan (Bailian, Глобальный/Международный) и всё готово!', - Custom: 'Пользовательский', - 'More instructions about configuring `modelProviders` manually.': - 'Дополнительные инструкции по ручной настройке `modelProviders`.', - 'Select API-KEY configuration mode:': 'Выберите режим конфигурации API-KEY:', - '(Press Escape to go back)': '(Нажмите Escape для возврата)', - '(Press Enter to submit, Escape to cancel)': - '(Нажмите Enter для отправки, Escape для отмены)', - 'More instructions please check:': 'Дополнительные инструкции см.:', + 'Select Region for Coding Plan': 'Выберите регион Coding Plan', + 'Choose based on where your account is registered': + 'Выберите в зависимости от места регистрации вашего аккаунта', + 'Enter Coding Plan API Key': 'Введите API-ключ Coding Plan', // ============================================================================ // Coding Plan International Updates // ============================================================================ 'New model configurations are available for {{region}}. Update now?': 'Доступны новые конфигурации моделей для {{region}}. Обновить сейчас?', - 'New model configurations are available for Bailian Coding Plan (China). Update now?': - 'Доступны новые конфигурации моделей для Bailian Coding Plan (Китай). Обновить сейчас?', - 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': - 'Доступны новые конфигурации моделей для Coding Plan (Bailian, Глобальный/Международный). Обновить сейчас?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': 'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".', - 'Authenticated successfully with {{region}}. API key is stored in settings.env.': - 'Успешная аутентификация с {{region}}. API-ключ сохранён в settings.env.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 57822793a..5a89f494d 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -173,6 +173,7 @@ export default { 'Enter to confirm, Esc to cancel': 'Enter 确认,Esc 取消', 'Enter to select, ↑↓ to navigate, Esc to go back': 'Enter 选择,↑↓ 导航,Esc 返回', + 'Enter to submit, Esc to go back': 'Enter 提交,Esc 返回', 'Invalid step: {{step}}': '无效步骤: {{step}}', 'No subagents found.': '未找到子智能体。', "Use '/agents create' to create your first subagent.": @@ -743,7 +744,6 @@ export default { 'missing name': '缺少名称', 'missing description': '缺少描述', '(unnamed)': '(未命名)', - unknown: '未知', 'Warning: This tool cannot be called by the LLM': '警告:此工具无法被 LLM 调用', Reason: '原因', @@ -934,18 +934,21 @@ export default { // Dialogs - Auth // ============================================================================ 'Get started': '开始使用', - 'How would you like to authenticate for this project?': - '您希望如何为此项目进行身份验证?', + 'Select Authentication Method': '选择认证方式', 'OpenAI API key is required to use OpenAI authentication.': '使用 OpenAI 认证需要 OpenAI API 密钥', '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 的服务条款和隐私声明', + 'Terms of Services and Privacy Notice': '服务条款和隐私声明', 'Qwen OAuth': 'Qwen OAuth (免费)', + 'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models': + '免费 \u00B7 每天最多 1,000 次请求 \u00B7 Qwen 最新模型', 'Login with QwenChat account to use daily free quota.': '使用 QwenChat 账号登录,享受每日免费额度。', + 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models': + '付费 \u00B7 每 5 小时最多 6,000 次请求 \u00B7 支持阿里云百炼 Coding Plan 全部模型', + 'Alibaba Cloud Coding Plan': '阿里云百炼 Coding Plan', + 'Bring your own API key': '使用自己的 API 密钥', 'Use coding plan credentials or your own api-keys/providers.': '使用 Coding Plan 凭证或您自己的 API 密钥/提供商。', OpenAI: 'OpenAI', @@ -969,6 +972,8 @@ export default { '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.': '注意:使用 Qwen OAuth 时,settings.json 中现有的 API 密钥不会被清除。如果需要,您可以稍后切换回 OpenAI 认证。', + 'Note: Your existing API key will not be cleared when using Qwen OAuth.': + '注意:使用 Qwen OAuth 时,现有的 API 密钥不会被清除。', 'Authentication timed out. Please try again.': '认证超时。请重试。', 'Waiting for auth... (Press ESC or CTRL+C to cancel)': '正在等待认证...(按 ESC 或 CTRL+C 取消)', @@ -1013,6 +1018,17 @@ export default { '(default)': '(默认)', '(set)': '(已设置)', '(not set)': '(未设置)', + Modality: '模态', + 'Context Window': '上下文窗口', + text: '文本', + 'text-only': '纯文本', + image: '图像', + pdf: 'PDF', + audio: '音频', + video: '视频', + 'not set': '未设置', + none: '无', + unknown: '未知', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "无法切换到模型 '{{modelId}}'.\n\n{{error}}", 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': @@ -1104,6 +1120,8 @@ export default { '按 Shift+Tab 或输入 /approval-mode 可快速切换权限模式。', 'You can switch permission mode quickly with Tab or /approval-mode.': '按 Tab 或输入 /approval-mode 可快速切换权限模式。', + 'Try /insight to generate personalized insights from your chat history.': + '试试 /insight,从聊天记录中生成个性化洞察。', // ============================================================================ // Exit Screen / Stats @@ -1267,18 +1285,22 @@ export default { 'Rate limit error: {{reason}}': '触发限流:{{reason}}', 'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})': '将于 {{seconds}} 秒后重试…(第 {{attempt}}/{{maxRetries}} 次)', + 'Press Ctrl+Y to retry': '按 Ctrl+Y 重试。', + 'No failed request to retry.': '没有可重试的失败请求。', + 'to retry last request': '重试上一次请求', // ============================================================================ // Coding Plan Authentication // ============================================================================ - 'Please enter your API key:': '请输入您的 API Key:', 'API key cannot be empty.': 'API Key 不能为空。', - 'You can get your exclusive Coding Plan API-KEY here:': - '您可以在这里获取专属的 Coding Plan API-KEY:', + 'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.': + '无效的 API Key,Coding Plan API Key 均以 "sk-sp-" 开头,请检查', + 'You can get your Coding Plan API key here': + '您可以在这里获取 Coding Plan API Key', 'API key is stored in settings.env. You can migrate it to a .env file for better security.': 'API Key 已存储在 settings.env 中。您可以将其迁移到 .env 文件以获得更好的安全性。', - 'New model configurations are available for Bailian Coding Plan. Update now?': - '百炼 Coding Plan 有新模型配置可用。是否立即更新?', + 'New model configurations are available for Alibaba Cloud Coding Plan. Update now?': + '阿里云百炼 Coding Plan 有新模型配置可用。是否立即更新?', 'Coding Plan configuration updated successfully. New models are now available.': 'Coding Plan 配置更新成功。新模型现已可用。', 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': @@ -1287,53 +1309,27 @@ export default { '更新 Coding Plan 配置失败:{{message}}', // ============================================================================ - // Custom API-KEY Configuration + // Custom API Key Configuration // ============================================================================ - 'For advanced users who want to configure models manually.': - '适合需要手动配置模型的高级用户。', - 'Please configure your models in settings.json:': - '请在 settings.json 中配置您的模型:', - 'Set API key via environment variable (e.g., OPENAI_API_KEY)': - '通过环境变量设置 API Key(例如:OPENAI_API_KEY)', - "Add model configuration to modelProviders['openai'] (or other auth types)": - "将模型配置添加到 modelProviders['openai'](或其他认证类型)", - 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig': - '每个提供商需要:id、envKey(必需),以及可选的 baseUrl、generationConfig', - 'Use /model command to select your preferred model from the configured list': - '使用 /model 命令从配置列表中选择您偏好的模型', - 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': - '支持的认证类型:openai、anthropic、gemini、vertex-ai 等', - 'More instructions please check:': '更多说明请查看:', + 'You can configure your API key and models in settings.json': + '您可以在 settings.json 中配置 API Key 和模型', + 'Refer to the documentation for setup instructions': '请参考文档了解配置说明', // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ - 'API-KEY': 'API-KEY', - 'Coding Plan': 'Coding Plan', - 'Coding Plan (Bailian, China)': 'Coding Plan (百炼, 中国)', - 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (百炼, 全球/国际)', - "Paste your api key of Bailian Coding Plan and you're all set!": - '粘贴您的百炼 Coding Plan API Key,即可完成设置!', - "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": - '粘贴您的 Coding Plan (百炼, 全球/国际) API Key,即可完成设置!', - Custom: '自定义', - 'More instructions about configuring `modelProviders` manually.': - '关于手动配置 `modelProviders` 的更多说明。', - 'Select API-KEY configuration mode:': '选择 API-KEY 配置模式:', - '(Press Escape to go back)': '(按 Escape 键返回)', - '(Press Enter to submit, Escape to cancel)': '(按 Enter 提交,Escape 取消)', + 'Select Region for Coding Plan': '选择 Coding Plan 区域', + 'Choose based on where your account is registered': + '请根据您的账号注册地区选择', + 'Enter Coding Plan API Key': '输入 Coding Plan API Key', // ============================================================================ // Coding Plan International Updates // ============================================================================ 'New model configurations are available for {{region}}. Update now?': '{{region}} 有新的模型配置可用。是否立即更新?', - 'New model configurations are available for Bailian Coding Plan (China). Update now?': - '百炼 Coding Plan (中国) 有新的模型配置可用。是否立即更新?', - 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': - 'Coding Plan (百炼, 全球/国际) 有新的模型配置可用。是否立即更新?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}} 配置更新成功。模型已切换至 "{{model}}"。', - 'Authenticated successfully with {{region}}. API key is stored in settings.env.': - '成功通过 {{region}} 认证。API Key 已存储在 settings.env 中。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + '成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json(已备份)。', }; diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index dc4c1f8d9..08ee98eb2 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -21,6 +21,7 @@ import { editorCommand } from '../ui/commands/editorCommand.js'; import { exportCommand } from '../ui/commands/exportCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; +import { hooksCommand } from '../ui/commands/hooksCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; import { initCommand } from '../ui/commands/initCommand.js'; import { languageCommand } from '../ui/commands/languageCommand.js'; @@ -40,6 +41,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; +import { insightCommand } from '../ui/commands/insightCommand.js'; /** * Loads the core, hard-coded slash commands that are an integral part @@ -71,6 +73,7 @@ export class BuiltinCommandLoader implements ICommandLoader { exportCommand, extensionsCommand, helpCommand, + hooksCommand, await ideCommand(), initCommand, languageCommand, @@ -90,6 +93,7 @@ export class BuiltinCommandLoader implements ICommandLoader { vimCommand, setupGithubCommand, terminalSetupCommand, + insightCommand, ]; return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); diff --git a/packages/cli/src/services/FileCommandLoader-markdown.test.ts b/packages/cli/src/services/FileCommandLoader-markdown.test.ts index 590f2d100..737cc39db 100644 --- a/packages/cli/src/services/FileCommandLoader-markdown.test.ts +++ b/packages/cli/src/services/FileCommandLoader-markdown.test.ts @@ -77,6 +77,30 @@ This is a test prompt from markdown.`; } }); + it('should load markdown commands with BOM and CRLF frontmatter', async () => { + const mdContent = + '\uFEFF---\r\ndescription: Windows markdown command\r\n---\r\n\r\nPrompt from windows markdown.\r\n'; + + const commandPath = path.join(tempDir, 'windows-command.md'); + await fs.writeFile(commandPath, mdContent, 'utf-8'); + + const loader = new FileCommandLoader(null); + const originalMethod = loader['getCommandDirectories']; + loader['getCommandDirectories'] = () => [{ path: tempDir }]; + + try { + const commands = await loader.loadCommands(new AbortController().signal); + const windowsCommand = commands.find( + (cmd) => cmd.name === 'windows-command', + ); + + expect(windowsCommand).toBeDefined(); + expect(windowsCommand?.description).toBe('Windows markdown command'); + } finally { + loader['getCommandDirectories'] = originalMethod; + } + }); + it('should load both toml and markdown commands', async () => { // Create both TOML and Markdown files const tomlContent = `prompt = "TOML prompt" diff --git a/packages/cli/src/services/insight/generators/DataProcessor.test.ts b/packages/cli/src/services/insight/generators/DataProcessor.test.ts new file mode 100644 index 000000000..1f90dbff5 --- /dev/null +++ b/packages/cli/src/services/insight/generators/DataProcessor.test.ts @@ -0,0 +1,1217 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { DataProcessor } from './DataProcessor.js'; +import type { Config, ChatRecord } from '@qwen-code/qwen-code-core'; +import type { + InsightData, + SessionFacets, +} from '../types/StaticInsightTypes.js'; + +// Mock dependencies +vi.mock('@qwen-code/qwen-code-core', async () => { + const actual = await vi.importActual< + typeof import('@qwen-code/qwen-code-core') + >('@qwen-code/qwen-code-core'); + return { + ...actual, + read: vi.fn(), + createDebugLogger: vi.fn(() => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + })), + }; +}); + +vi.mock('fs/promises', () => ({ + default: { + readdir: vi.fn(), + stat: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + }, +})); + +import fs from 'fs/promises'; +import { read as readJsonlFile } from '@qwen-code/qwen-code-core'; + +const mockedFs = vi.mocked(fs); +const mockedReadJsonlFile = vi.mocked(readJsonlFile); + +describe('DataProcessor', () => { + let mockConfig: Config; + let dataProcessor: DataProcessor; + let mockGenerateJson: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockGenerateJson = vi.fn(); + mockConfig = { + getBaseLlmClient: vi.fn(() => ({ + generateJson: mockGenerateJson, + })), + getModel: vi.fn(() => 'test-model'), + } as unknown as Config; + + dataProcessor = new DataProcessor(mockConfig); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('formatDate', () => { + it('should format date as YYYY-MM-DD', () => { + const date = new Date('2025-01-15T10:30:00Z'); + // Access private method through any cast for testing + const result = ( + dataProcessor as unknown as { formatDate(date: Date): string } + ).formatDate(date); + expect(result).toBe('2025-01-15'); + }); + + it('should handle different timezones correctly', () => { + const date = new Date('2025-12-31T23:59:59Z'); + const result = ( + dataProcessor as unknown as { formatDate(date: Date): string } + ).formatDate(date); + // Result depends on local timezone, but should be a valid date string + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + }); + + describe('formatRecordsForAnalysis', () => { + it('should format empty records array', () => { + const records: ChatRecord[] = []; + const result = ( + dataProcessor as unknown as { + formatRecordsForAnalysis(records: ChatRecord[]): string; + } + ).formatRecordsForAnalysis(records); + expect(result).toContain('Session: unknown'); + expect(result).toContain('Duration: 0 turns'); + }); + + it('should format user messages correctly', () => { + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Hello, world!' }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + const result = ( + dataProcessor as unknown as { + formatRecordsForAnalysis(records: ChatRecord[]): string; + } + ).formatRecordsForAnalysis(records); + expect(result).toContain('Session: test-session'); + expect(result).toContain('[User]: Hello, world!'); + }); + + it('should format assistant text messages correctly', () => { + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'assistant', + message: { + role: 'assistant', + parts: [{ text: 'I can help you with that.' }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + const result = ( + dataProcessor as unknown as { + formatRecordsForAnalysis(records: ChatRecord[]): string; + } + ).formatRecordsForAnalysis(records); + expect(result).toContain('[Assistant]: I can help you with that.'); + }); + + it('should format function calls correctly', () => { + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'assistant', + message: { + role: 'assistant', + parts: [{ functionCall: { name: 'read_file', args: {} } }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + const result = ( + dataProcessor as unknown as { + formatRecordsForAnalysis(records: ChatRecord[]): string; + } + ).formatRecordsForAnalysis(records); + expect(result).toContain('[Tool: read_file]'); + }); + + it('should handle multiple message parts', () => { + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'assistant', + message: { + role: 'assistant', + parts: [ + { text: 'Let me check that.' }, + { functionCall: { name: 'search', args: {} } }, + ], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + const result = ( + dataProcessor as unknown as { + formatRecordsForAnalysis(records: ChatRecord[]): string; + } + ).formatRecordsForAnalysis(records); + expect(result).toContain('[Assistant]: Let me check that.'); + expect(result).toContain('[Tool: search]'); + }); + + it('should handle messages without parts', () => { + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'assistant', + message: { + role: 'assistant', + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + const result = ( + dataProcessor as unknown as { + formatRecordsForAnalysis(records: ChatRecord[]): string; + } + ).formatRecordsForAnalysis(records); + expect(result).not.toContain('[Assistant]:'); + }); + }); + + describe('calculateStreaks', () => { + it('should return zero streaks for empty dates array', () => { + const result = ( + dataProcessor as unknown as { + calculateStreaks(dates: string[]): { + currentStreak: number; + longestStreak: number; + dates: string[]; + }; + } + ).calculateStreaks([]); + expect(result.currentStreak).toBe(0); + expect(result.longestStreak).toBe(0); + expect(result.dates).toEqual([]); + }); + + it('should calculate streak of 1 for single date', () => { + const result = ( + dataProcessor as unknown as { + calculateStreaks(dates: string[]): { + currentStreak: number; + longestStreak: number; + dates: string[]; + }; + } + ).calculateStreaks(['2025-01-15']); + expect(result.currentStreak).toBe(1); + expect(result.longestStreak).toBe(1); + }); + + it('should calculate consecutive day streak', () => { + const dates = ['2025-01-15', '2025-01-16', '2025-01-17']; + const result = ( + dataProcessor as unknown as { + calculateStreaks(dates: string[]): { + currentStreak: number; + longestStreak: number; + dates: string[]; + }; + } + ).calculateStreaks(dates); + expect(result.currentStreak).toBe(3); + expect(result.longestStreak).toBe(3); + }); + + it('should handle non-consecutive dates', () => { + const dates = ['2025-01-15', '2025-01-17', '2025-01-18']; + const result = ( + dataProcessor as unknown as { + calculateStreaks(dates: string[]): { + currentStreak: number; + longestStreak: number; + dates: string[]; + }; + } + ).calculateStreaks(dates); + expect(result.longestStreak).toBe(2); // Jan 17-18 + }); + + it('should sort dates before calculating streaks', () => { + const dates = ['2025-01-18', '2025-01-15', '2025-01-16', '2025-01-17']; + const result = ( + dataProcessor as unknown as { + calculateStreaks(dates: string[]): { + currentStreak: number; + longestStreak: number; + dates: string[]; + }; + } + ).calculateStreaks(dates); + expect(result.longestStreak).toBe(4); + }); + + it('should handle duplicate dates', () => { + const dates = ['2025-01-15', '2025-01-15', '2025-01-16']; + const result = ( + dataProcessor as unknown as { + calculateStreaks(dates: string[]): { + currentStreak: number; + longestStreak: number; + dates: string[]; + }; + } + ).calculateStreaks(dates); + expect(result.longestStreak).toBeGreaterThanOrEqual(1); + }); + }); + + describe('aggregateFacetsData', () => { + it('should return empty aggregates for empty facets array', () => { + const result = ( + dataProcessor as unknown as { + aggregateFacetsData(facets: SessionFacets[]): { + satisfactionAgg: Record; + frictionAgg: Record; + primarySuccessAgg: Record; + outcomesAgg: Record; + goalsAgg: Record; + }; + } + ).aggregateFacetsData([]); + expect(result.satisfactionAgg).toEqual({}); + expect(result.frictionAgg).toEqual({}); + expect(result.primarySuccessAgg).toEqual({}); + expect(result.outcomesAgg).toEqual({}); + expect(result.goalsAgg).toEqual({}); + }); + + it('should aggregate satisfaction counts', () => { + const facets: SessionFacets[] = [ + { + session_id: 's1', + underlying_goal: 'test', + goal_categories: {}, + outcome: 'fully_achieved', + user_satisfaction_counts: { satisfied: 2, neutral: 1 }, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary', + }, + { + session_id: 's2', + underlying_goal: 'test2', + goal_categories: {}, + outcome: 'mostly_achieved', + user_satisfaction_counts: { satisfied: 1, frustrated: 2 }, + Qwen_helpfulness: 'moderately_helpful', + session_type: 'multi_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary 2', + }, + ]; + const result = ( + dataProcessor as unknown as { + aggregateFacetsData(facets: SessionFacets[]): { + satisfactionAgg: Record; + }; + } + ).aggregateFacetsData(facets); + expect(result.satisfactionAgg).toEqual({ + satisfied: 3, + neutral: 1, + frustrated: 2, + }); + }); + + it('should aggregate friction counts', () => { + const facets: SessionFacets[] = [ + { + session_id: 's1', + underlying_goal: 'test', + goal_categories: {}, + outcome: 'fully_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: { slow_response: 1, unclear_answer: 2 }, + friction_detail: 'Some friction', + primary_success: 'none', + brief_summary: 'Test summary', + }, + { + session_id: 's2', + underlying_goal: 'test2', + goal_categories: {}, + outcome: 'mostly_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'moderately_helpful', + session_type: 'multi_task', + friction_counts: { slow_response: 2 }, + friction_detail: 'More friction', + primary_success: 'none', + brief_summary: 'Test summary 2', + }, + ]; + const result = ( + dataProcessor as unknown as { + aggregateFacetsData(facets: SessionFacets[]): { + frictionAgg: Record; + }; + } + ).aggregateFacetsData(facets); + expect(result.frictionAgg).toEqual({ + slow_response: 3, + unclear_answer: 2, + }); + }); + + it('should aggregate primary success excluding none', () => { + const facets: SessionFacets[] = [ + { + session_id: 's1', + underlying_goal: 'test', + goal_categories: {}, + outcome: 'fully_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'correct_code_edits', + brief_summary: 'Test summary', + }, + { + session_id: 's2', + underlying_goal: 'test2', + goal_categories: {}, + outcome: 'mostly_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'moderately_helpful', + session_type: 'multi_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary 2', + }, + { + session_id: 's3', + underlying_goal: 'test3', + goal_categories: {}, + outcome: 'partially_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'slightly_helpful', + session_type: 'exploration', + friction_counts: {}, + friction_detail: '', + primary_success: 'good_explanations', + brief_summary: 'Test summary 3', + }, + ]; + const result = ( + dataProcessor as unknown as { + aggregateFacetsData(facets: SessionFacets[]): { + primarySuccessAgg: Record; + }; + } + ).aggregateFacetsData(facets); + expect(result.primarySuccessAgg).toEqual({ + correct_code_edits: 1, + good_explanations: 1, + }); + expect(result.primarySuccessAgg['none']).toBeUndefined(); + }); + + it('should aggregate outcomes', () => { + const facets: SessionFacets[] = [ + { + session_id: 's1', + underlying_goal: 'test', + goal_categories: {}, + outcome: 'fully_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary', + }, + { + session_id: 's2', + underlying_goal: 'test2', + goal_categories: {}, + outcome: 'fully_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'moderately_helpful', + session_type: 'multi_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary 2', + }, + { + session_id: 's3', + underlying_goal: 'test3', + goal_categories: {}, + outcome: 'partially_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'slightly_helpful', + session_type: 'exploration', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary 3', + }, + ]; + const result = ( + dataProcessor as unknown as { + aggregateFacetsData(facets: SessionFacets[]): { + outcomesAgg: Record; + }; + } + ).aggregateFacetsData(facets); + expect(result.outcomesAgg).toEqual({ + fully_achieved: 2, + partially_achieved: 1, + }); + }); + + it('should aggregate goal categories', () => { + const facets: SessionFacets[] = [ + { + session_id: 's1', + underlying_goal: 'test', + goal_categories: { coding: 2, debugging: 1 }, + outcome: 'fully_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary', + }, + { + session_id: 's2', + underlying_goal: 'test2', + goal_categories: { coding: 1, refactoring: 3 }, + outcome: 'mostly_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'moderately_helpful', + session_type: 'multi_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary 2', + }, + ]; + const result = ( + dataProcessor as unknown as { + aggregateFacetsData(facets: SessionFacets[]): { + goalsAgg: Record; + }; + } + ).aggregateFacetsData(facets); + expect(result.goalsAgg).toEqual({ + coding: 3, + debugging: 1, + refactoring: 3, + }); + }); + }); + + describe('analyzeSession', () => { + it('should return null for empty records', async () => { + const result = await ( + dataProcessor as unknown as { + analyzeSession(records: ChatRecord[]): Promise; + } + ).analyzeSession([]); + expect(result).toBeNull(); + }); + + it('should analyze session and return facets', async () => { + const mockFacet = { + underlying_goal: 'Test goal', + goal_categories: { coding: 1 }, + outcome: 'fully_achieved', + user_satisfaction_counts: { satisfied: 1 }, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'correct_code_edits', + brief_summary: 'Test summary', + }; + + mockGenerateJson.mockResolvedValue(mockFacet); + + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Help me with code' }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + const result = await ( + dataProcessor as unknown as { + analyzeSession(records: ChatRecord[]): Promise; + } + ).analyzeSession(records); + + expect(result).not.toBeNull(); + expect(result?.session_id).toBe('test-session'); + expect(result?.underlying_goal).toBe('Test goal'); + expect(mockGenerateJson).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'test-model', + schema: expect.any(Object), + }), + ); + }); + + it('should return null when LLM returns empty result', async () => { + mockGenerateJson.mockResolvedValue({}); + + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Help' }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + const result = await ( + dataProcessor as unknown as { + analyzeSession(records: ChatRecord[]): Promise; + } + ).analyzeSession(records); + + expect(result).toBeNull(); + }); + + it('should handle LLM errors gracefully', async () => { + mockGenerateJson.mockRejectedValue(new Error('LLM Error')); + + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Help' }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + const result = await ( + dataProcessor as unknown as { + analyzeSession(records: ChatRecord[]): Promise; + } + ).analyzeSession(records); + + expect(result).toBeNull(); + }); + }); + + describe('scanChatFiles', () => { + it('should return empty array when base directory does not exist', async () => { + const error = new Error('Directory not found') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + mockedFs.readdir.mockRejectedValue(error); + + const result = await ( + dataProcessor as unknown as { + scanChatFiles( + baseDir: string, + ): Promise>; + } + ).scanChatFiles('/nonexistent'); + + expect(result).toEqual([]); + }); + + it('should scan project directories and find chat files', async () => { + mockedFs.readdir.mockResolvedValueOnce([ + 'project1', + 'project2', + ] as unknown as Awaited>); + + mockedFs.stat.mockImplementation((path) => { + const pathStr = String(path); + if (pathStr.includes('project1') || pathStr.includes('project2')) { + return Promise.resolve({ + isDirectory: () => true, + mtimeMs: 1234567890, + } as Awaited>); + } + if (pathStr.endsWith('.jsonl')) { + return Promise.resolve({ + isDirectory: () => false, + mtimeMs: 1234567890, + } as Awaited>); + } + throw new Error('Unexpected path'); + }); + + mockedFs.readdir.mockImplementation((path) => { + const pathStr = String(path); + if (pathStr.endsWith('chats')) { + if (pathStr.includes('project1')) { + return Promise.resolve([ + 'chat1.jsonl', + 'chat2.jsonl', + ] as unknown as Awaited>); + } + if (pathStr.includes('project2')) { + return Promise.resolve(['chat3.jsonl'] as unknown as Awaited< + ReturnType + >); + } + } + return Promise.resolve( + [] as unknown as Awaited>, + ); + }); + + const result = await ( + dataProcessor as unknown as { + scanChatFiles( + baseDir: string, + ): Promise>; + } + ).scanChatFiles('/base'); + + expect(result).toHaveLength(3); + const paths = result.map((r) => r.path); + expect(paths.some((p) => p.includes('chat1.jsonl'))).toBe(true); + expect(paths.some((p) => p.includes('chat2.jsonl'))).toBe(true); + expect(paths.some((p) => p.includes('chat3.jsonl'))).toBe(true); + }); + + it('should skip projects without chats directory', async () => { + mockedFs.readdir.mockResolvedValueOnce([ + 'project1', + 'project2', + ] as unknown as Awaited>); + + mockedFs.stat.mockImplementation((path) => { + const pathStr = String(path); + if (pathStr.includes('project1') || pathStr.includes('project2')) { + return Promise.resolve({ isDirectory: () => true } as Awaited< + ReturnType + >); + } + if (pathStr.endsWith('.jsonl')) { + return Promise.resolve({ + isDirectory: () => false, + mtimeMs: 1234567890, + } as Awaited>); + } + throw new Error('Unexpected path'); + }); + + const error = new Error('No chats dir') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + + mockedFs.readdir.mockImplementation((path) => { + const pathStr = String(path); + if (pathStr.endsWith('chats')) { + if (pathStr.includes('project1')) { + return Promise.resolve(['chat1.jsonl'] as unknown as Awaited< + ReturnType + >); + } + if (pathStr.includes('project2')) { + return Promise.reject(error); + } + } + return Promise.resolve( + [] as unknown as Awaited>, + ); + }); + + const result = await ( + dataProcessor as unknown as { + scanChatFiles( + baseDir: string, + ): Promise>; + } + ).scanChatFiles('/base'); + + expect(result).toHaveLength(1); + expect(result[0].path).toContain('chat1.jsonl'); + }); + + it('should handle file stat errors gracefully', async () => { + mockedFs.readdir.mockResolvedValueOnce(['project1'] as unknown as Awaited< + ReturnType + >); + + mockedFs.stat.mockImplementation((path) => { + const pathStr = String(path); + if (pathStr.includes('project1') && !pathStr.includes('chats')) { + return Promise.resolve({ isDirectory: () => true } as Awaited< + ReturnType + >); + } + if (pathStr.endsWith('chat1.jsonl')) { + return Promise.reject(new Error('Stat failed')); + } + throw new Error('Unexpected path: ' + pathStr); + }); + + mockedFs.readdir.mockImplementation((path) => { + const pathStr = String(path); + if (pathStr.endsWith('chats')) { + return Promise.resolve(['chat1.jsonl'] as unknown as Awaited< + ReturnType + >); + } + return Promise.resolve( + [] as unknown as Awaited>, + ); + }); + + const result = await ( + dataProcessor as unknown as { + scanChatFiles( + baseDir: string, + ): Promise>; + } + ).scanChatFiles('/base'); + + // When stat fails for a file, it should be skipped but not crash + expect(result).toEqual([]); + }); + }); + + describe('generateMetrics', () => { + it('should generate metrics from chat files', async () => { + const mockRecords: ChatRecord[] = [ + { + sessionId: 'session1', + timestamp: '2025-01-15T10:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Hello' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + { + sessionId: 'session1', + timestamp: '2025-01-15T10:01:00Z', + type: 'system', + subtype: 'slash_command', + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + { + sessionId: 'session1', + timestamp: '2025-01-15T10:05:00Z', + type: 'assistant', + message: { role: 'assistant', parts: [{ text: 'Hi' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + { + sessionId: 'session1', + timestamp: '2025-01-15T10:06:00Z', + type: 'assistant', + message: { + role: 'assistant', + parts: [{ functionCall: { name: 'read_file', args: {} } }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + mockedReadJsonlFile.mockResolvedValue(mockRecords); + + const files = [{ path: '/test/chat.jsonl', mtime: 1234567890 }]; + const result = await ( + dataProcessor as unknown as { + generateMetrics( + files: Array<{ path: string; mtime: number }>, + ): Promise; + } + ).generateMetrics(files); + + expect(result).toMatchObject({ + totalMessages: 2, + totalSessions: 1, + heatmap: expect.any(Object), + activeHours: expect.any(Object), + topTools: expect.any(Array), + }); + }); + + it('should track tool usage correctly', async () => { + const mockRecords: ChatRecord[] = [ + { + sessionId: 'session1', + timestamp: '2025-01-15T10:00:00Z', + type: 'assistant', + message: { + role: 'assistant', + parts: [{ functionCall: { name: 'read_file', args: {} } }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + { + sessionId: 'session1', + timestamp: '2025-01-15T10:01:00Z', + type: 'assistant', + message: { + role: 'assistant', + parts: [{ functionCall: { name: 'read_file', args: {} } }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + { + sessionId: 'session1', + timestamp: '2025-01-15T10:02:00Z', + type: 'assistant', + message: { + role: 'assistant', + parts: [{ functionCall: { name: 'write_file', args: {} } }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + mockedReadJsonlFile.mockResolvedValue(mockRecords); + + const files = [{ path: '/test/chat.jsonl', mtime: 1234567890 }]; + const result = await ( + dataProcessor as unknown as { + generateMetrics( + files: Array<{ path: string; mtime: number }>, + ): Promise<{ topTools: Array<[string, number]> }>; + } + ).generateMetrics(files); + + expect(result.topTools).toContainEqual(['read_file', 2]); + expect(result.topTools).toContainEqual(['write_file', 1]); + }); + + it('should handle file read errors gracefully', async () => { + mockedReadJsonlFile.mockRejectedValue(new Error('Read failed')); + + const files = [{ path: '/test/chat.jsonl', mtime: 1234567890 }]; + const result = await ( + dataProcessor as unknown as { + generateMetrics( + files: Array<{ path: string; mtime: number }>, + ): Promise<{ totalMessages: number }>; + } + ).generateMetrics(files); + + expect(result.totalMessages).toBe(0); + }); + + it('should call progress callback during processing', async () => { + const mockRecords: ChatRecord[] = [ + { + sessionId: 'session1', + timestamp: '2025-01-15T10:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Hello' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + mockedReadJsonlFile.mockResolvedValue(mockRecords); + + const files = [ + { path: '/test/chat1.jsonl', mtime: 1234567890 }, + { path: '/test/chat2.jsonl', mtime: 1234567891 }, + ]; + const onProgress = vi.fn(); + + await ( + dataProcessor as unknown as { + generateMetrics( + files: Array<{ path: string; mtime: number }>, + onProgress?: (stage: string, progress: number) => void, + ): Promise; + } + ).generateMetrics(files, onProgress); + + expect(onProgress).toHaveBeenCalled(); + }); + }); + + describe('prepareCommonPromptData', () => { + it('should prepare prompt data with all required sections', () => { + const metrics = { + heatmap: { '2025-01-15': 5, '2025-01-16': 3 }, + totalSessions: 10, + totalMessages: 100, + totalHours: 5, + topTools: [ + ['read_file', 20], + ['write_file', 10], + ], + } as unknown as Omit; + + const facets: SessionFacets[] = [ + { + session_id: 's1', + underlying_goal: 'Goal 1', + goal_categories: { coding: 2, debugging: 1 }, + outcome: 'fully_achieved', + user_satisfaction_counts: { satisfied: 2 }, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: { slow: 1 }, + friction_detail: 'Some friction detail', + primary_success: 'correct_code_edits', + brief_summary: 'Summary 1', + }, + ]; + + const result = ( + dataProcessor as unknown as { + prepareCommonPromptData( + metrics: Omit, + facets: SessionFacets[], + ): string; + } + ).prepareCommonPromptData(metrics, facets); + + expect(result).toContain('DATA:'); + expect(result).toContain('SESSION SUMMARIES:'); + expect(result).toContain('FRICTION DETAILS:'); + expect(result).toContain('Summary 1'); + expect(result).toContain('Some friction detail'); + }); + + it('should filter out empty friction details', () => { + const metrics = { + heatmap: {}, + totalSessions: 1, + totalMessages: 10, + totalHours: 1, + topTools: [], + } as unknown as Omit; + + const facets: SessionFacets[] = [ + { + session_id: 's1', + underlying_goal: 'Goal 1', + goal_categories: {}, + outcome: 'fully_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Summary 1', + }, + { + session_id: 's2', + underlying_goal: 'Goal 2', + goal_categories: {}, + outcome: 'mostly_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'moderately_helpful', + session_type: 'multi_task', + friction_counts: {}, + friction_detail: ' ', + primary_success: 'none', + brief_summary: 'Summary 2', + }, + ]; + + const result = ( + dataProcessor as unknown as { + prepareCommonPromptData( + metrics: Omit, + facets: SessionFacets[], + ): string; + } + ).prepareCommonPromptData(metrics, facets); + + // Check that FRICTION DETAILS section is empty or only contains whitespace + const frictionSection = + result.split('FRICTION DETAILS:')[1]?.split('USER INSTRUCTIONS')[0] || + ''; + const hasNonEmptyFrictionDetail = + frictionSection.trim().length > 0 && frictionSection.includes('-'); + expect(hasNonEmptyFrictionDetail).toBe(false); + }); + }); + + describe('generateFacets', () => { + it('should skip non-conversational sessions', async () => { + const userOnlyRecords: ChatRecord[] = [ + { + sessionId: 'user-only', + timestamp: '2025-01-15T10:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Hello' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + const conversationalRecords: ChatRecord[] = [ + { + sessionId: 'conversational', + timestamp: '2025-01-15T10:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Hello' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + { + sessionId: 'conversational', + timestamp: '2025-01-15T10:01:00Z', + type: 'assistant', + message: { role: 'assistant', parts: [{ text: 'Hi' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + // First file is user-only, second is conversational + mockedReadJsonlFile + .mockResolvedValueOnce(userOnlyRecords) + .mockResolvedValueOnce(conversationalRecords); + + const mockFacet = { + underlying_goal: 'Test', + goal_categories: {}, + outcome: 'fully_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test', + }; + mockGenerateJson.mockResolvedValue(mockFacet); + + const files = [ + { path: '/test/user-only.jsonl', mtime: 2000 }, + { path: '/test/conversational.jsonl', mtime: 1000 }, + ]; + + const result = await ( + dataProcessor as unknown as { + generateFacets( + files: Array<{ path: string; mtime: number }>, + facetsOutputDir?: string, + ): Promise; + } + ).generateFacets(files); + + // Only the conversational session should be analyzed + expect(mockGenerateJson).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + expect(result[0].session_id).toBe('conversational'); + }); + }); +}); diff --git a/packages/cli/src/services/insight/generators/DataProcessor.ts b/packages/cli/src/services/insight/generators/DataProcessor.ts new file mode 100644 index 000000000..a3cda424e --- /dev/null +++ b/packages/cli/src/services/insight/generators/DataProcessor.ts @@ -0,0 +1,1131 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { + read as readJsonlFile, + createDebugLogger, +} from '@qwen-code/qwen-code-core'; +import pLimit from 'p-limit'; +import type { + InsightData, + HeatMapData, + StreakData, + SessionFacets, + InsightProgressCallback, +} from '../types/StaticInsightTypes.js'; +import type { + QualitativeInsights, + InsightImpressiveWorkflows, + InsightProjectAreas, + InsightFutureOpportunities, + InsightFrictionPoints, + InsightMemorableMoment, + InsightImprovements, + InsightInteractionStyle, + InsightAtAGlance, +} from '../types/QualitativeInsightTypes.js'; +import { + getInsightPrompt, + type Config, + type ChatRecord, +} from '@qwen-code/qwen-code-core'; + +const logger = createDebugLogger('DataProcessor'); + +const CONCURRENCY_LIMIT = 4; + +export class DataProcessor { + constructor(private config: Config) {} + + // Helper function to format date as YYYY-MM-DD + private formatDate(date: Date): string { + return date.toISOString().split('T')[0]; + } + + // Format chat records for LLM analysis + private formatRecordsForAnalysis(records: ChatRecord[]): string { + let output = ''; + const sessionStart = + records.length > 0 ? new Date(records[0].timestamp) : new Date(); + + output += `Session: ${records[0]?.sessionId || 'unknown'}\n`; + output += `Date: ${sessionStart.toISOString()}\n`; + output += `Duration: ${records.length} turns\n\n`; + + for (const record of records) { + if (record.type === 'user') { + const text = + record.message?.parts + ?.map((p) => ('text' in p ? p.text : '')) + .join('') || ''; + output += `[User]: ${text}\n`; + } else if (record.type === 'assistant') { + if (record.message?.parts) { + for (const part of record.message.parts) { + if ('text' in part && part.text) { + output += `[Assistant]: ${part.text}\n`; + } else if ('functionCall' in part) { + const call = part.functionCall; + if (call) { + output += `[Tool: ${call.name}]\n`; + } + } + } + } + } + } + return output; + } + + // Only analyze conversational sessions for facets (skip system-only logs). + private hasUserAndAssistantRecords(records: ChatRecord[]): boolean { + let hasUser = false; + let hasAssistant = false; + + for (const record of records) { + if (record.type === 'user') { + hasUser = true; + } else if (record.type === 'assistant') { + hasAssistant = true; + } + + if (hasUser && hasAssistant) { + return true; + } + } + + return false; + } + + // Analyze a single session using LLM + private async analyzeSession( + records: ChatRecord[], + ): Promise { + if (records.length === 0) return null; + + const INSIGHT_SCHEMA = { + type: 'object', + properties: { + underlying_goal: { + type: 'string', + description: 'What the user fundamentally wanted to achieve', + }, + goal_categories: { + type: 'object', + additionalProperties: { type: 'number' }, + }, + outcome: { + type: 'string', + enum: [ + 'fully_achieved', + 'mostly_achieved', + 'partially_achieved', + 'not_achieved', + 'unclear_from_transcript', + ], + }, + user_satisfaction_counts: { + type: 'object', + additionalProperties: { type: 'number' }, + }, + Qwen_helpfulness: { + type: 'string', + enum: [ + 'unhelpful', + 'slightly_helpful', + 'moderately_helpful', + 'very_helpful', + 'essential', + ], + }, + session_type: { + type: 'string', + enum: [ + 'single_task', + 'multi_task', + 'iterative_refinement', + 'exploration', + 'quick_question', + ], + }, + friction_counts: { + type: 'object', + additionalProperties: { type: 'number' }, + }, + friction_detail: { + type: 'string', + description: 'One sentence describing friction or empty', + }, + primary_success: { + type: 'string', + enum: [ + 'none', + 'fast_accurate_search', + 'correct_code_edits', + 'good_explanations', + 'proactive_help', + 'multi_file_changes', + 'good_debugging', + ], + }, + brief_summary: { + type: 'string', + description: 'One sentence: what user wanted and whether they got it', + }, + }, + required: [ + 'underlying_goal', + 'goal_categories', + 'outcome', + 'user_satisfaction_counts', + 'Qwen_helpfulness', + 'session_type', + 'friction_counts', + 'friction_detail', + 'primary_success', + 'brief_summary', + ], + }; + + const sessionText = this.formatRecordsForAnalysis(records); + const prompt = `${getInsightPrompt('analysis')}\n\nSESSION:\n${sessionText}`; + + try { + const result = await this.config.getBaseLlmClient().generateJson({ + // Use the configured model + model: this.config.getModel(), + contents: [{ role: 'user', parts: [{ text: prompt }] }], + schema: INSIGHT_SCHEMA, + abortSignal: AbortSignal.timeout(600000), // 10 minute timeout per session + }); + + if (!result || Object.keys(result).length === 0) { + return null; + } + + return { + ...(result as unknown as SessionFacets), + session_id: records[0].sessionId, + }; + } catch (error) { + logger.error( + `Failed to analyze session ${records[0]?.sessionId}:`, + error, + ); + return null; + } + } + + // Calculate streaks from activity dates + private calculateStreaks(dates: string[]): StreakData { + if (dates.length === 0) { + return { currentStreak: 0, longestStreak: 0, dates: [] }; + } + + // Convert string dates to Date objects and sort them + const dateObjects = dates.map((dateStr) => new Date(dateStr)); + dateObjects.sort((a, b) => a.getTime() - b.getTime()); + + let currentStreak = 1; + let maxStreak = 1; + let currentDate = new Date(dateObjects[0]); + currentDate.setHours(0, 0, 0, 0); // Normalize to start of day + + for (let i = 1; i < dateObjects.length; i++) { + const nextDate = new Date(dateObjects[i]); + nextDate.setHours(0, 0, 0, 0); // Normalize to start of day + + // Calculate difference in days + const diffDays = Math.floor( + (nextDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24), + ); + + if (diffDays === 1) { + // Consecutive day + currentStreak++; + maxStreak = Math.max(maxStreak, currentStreak); + } else if (diffDays > 1) { + // Gap in streak + currentStreak = 1; + } + // If diffDays === 0, same day, so streak continues + + currentDate = nextDate; + } + + // Check if the streak is still ongoing (if last activity was yesterday or today) + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if ( + currentDate.getTime() === today.getTime() || + currentDate.getTime() === yesterday.getTime() + ) { + // The streak might still be active, so we don't reset it + } + + return { + currentStreak, + longestStreak: maxStreak, + dates, + }; + } + + // Process chat files from all projects in the base directory and generate insights + async generateInsights( + baseDir: string, + facetsOutputDir?: string, + onProgress?: InsightProgressCallback, + ): Promise { + if (onProgress) onProgress('Scanning chat history...', 0); + const allChatFiles = await this.scanChatFiles(baseDir); + + if (onProgress) onProgress('Crunching the numbers', 10); + const metrics = await this.generateMetrics(allChatFiles, onProgress); + + if (onProgress) onProgress('Preparing sessions...', 20); + const facets = await this.generateFacets( + allChatFiles, + facetsOutputDir, + onProgress, + ); + + if (onProgress) onProgress('Generating personalized insights...', 80); + const qualitative = await this.generateQualitativeInsights(metrics, facets); + + // Aggregate satisfaction, friction, success and outcome data from facets + const { + satisfactionAgg, + frictionAgg, + primarySuccessAgg, + outcomesAgg, + goalsAgg, + } = this.aggregateFacetsData(facets); + + if (onProgress) onProgress('Assembling report...', 100); + + return { + ...metrics, + qualitative, + satisfaction: satisfactionAgg, + friction: frictionAgg, + primarySuccess: primarySuccessAgg, + outcomes: outcomesAgg, + topGoals: goalsAgg, + }; + } + + // Aggregate satisfaction and friction data from facets + private aggregateFacetsData(facets: SessionFacets[]): { + satisfactionAgg: Record; + frictionAgg: Record; + primarySuccessAgg: Record; + outcomesAgg: Record; + goalsAgg: Record; + } { + const satisfactionAgg: Record = {}; + const frictionAgg: Record = {}; + const primarySuccessAgg: Record = {}; + const outcomesAgg: Record = {}; + const goalsAgg: Record = {}; + + facets.forEach((facet) => { + // Aggregate satisfaction + Object.entries(facet.user_satisfaction_counts).forEach(([sat, count]) => { + satisfactionAgg[sat] = (satisfactionAgg[sat] || 0) + count; + }); + + // Aggregate friction + Object.entries(facet.friction_counts).forEach(([fric, count]) => { + frictionAgg[fric] = (frictionAgg[fric] || 0) + count; + }); + + // Aggregate primary success + if (facet.primary_success && facet.primary_success !== 'none') { + primarySuccessAgg[facet.primary_success] = + (primarySuccessAgg[facet.primary_success] || 0) + 1; + } + + // Aggregate outcomes + if (facet.outcome) { + outcomesAgg[facet.outcome] = (outcomesAgg[facet.outcome] || 0) + 1; + } + + // Aggregate goals + Object.entries(facet.goal_categories).forEach(([goal, count]) => { + goalsAgg[goal] = (goalsAgg[goal] || 0) + count; + }); + }); + + return { + satisfactionAgg, + frictionAgg, + primarySuccessAgg, + outcomesAgg, + goalsAgg, + }; + } + + private async generateQualitativeInsights( + metrics: Omit, + facets: SessionFacets[], + ): Promise { + if (facets.length === 0) { + return undefined; + } + + logger.info('Generating qualitative insights...'); + + const commonData = this.prepareCommonPromptData(metrics, facets); + + const generate = async ( + promptTemplate: string, + schema: Record, + ): Promise => { + const prompt = `${promptTemplate}\n\n${commonData}`; + try { + const result = await this.config.getBaseLlmClient().generateJson({ + model: this.config.getModel(), + contents: [{ role: 'user', parts: [{ text: prompt }] }], + schema, + abortSignal: AbortSignal.timeout(600000), + }); + return result as T; + } catch (error) { + logger.error('Failed to generate insight:', error); + throw error; + } + }; + + // Schemas for each insight type + // We define simplified schemas here to guide the LLM. + // The types are already defined in QualitativeInsightTypes.ts + + // 1. Impressive Workflows + const schemaImpressiveWorkflows = { + type: 'object', + properties: { + intro: { type: 'string' }, + impressive_workflows: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + description: { type: 'string' }, + }, + required: ['title', 'description'], + }, + }, + }, + required: ['intro', 'impressive_workflows'], + }; + + // 2. Project Areas + const schemaProjectAreas = { + type: 'object', + properties: { + areas: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + session_count: { type: 'number' }, + description: { type: 'string' }, + }, + required: ['name', 'session_count', 'description'], + }, + }, + }, + required: ['areas'], + }; + + // 3. Future Opportunities + const schemaFutureOpportunities = { + type: 'object', + properties: { + intro: { type: 'string' }, + opportunities: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + whats_possible: { type: 'string' }, + how_to_try: { type: 'string' }, + copyable_prompt: { type: 'string' }, + }, + required: [ + 'title', + 'whats_possible', + 'how_to_try', + 'copyable_prompt', + ], + }, + }, + }, + required: ['intro', 'opportunities'], + }; + + // 4. Friction Points + const schemaFrictionPoints = { + type: 'object', + properties: { + intro: { type: 'string' }, + categories: { + type: 'array', + items: { + type: 'object', + properties: { + category: { type: 'string' }, + description: { type: 'string' }, + examples: { type: 'array', items: { type: 'string' } }, + }, + required: ['category', 'description', 'examples'], + }, + }, + }, + required: ['intro', 'categories'], + }; + + // 5. Memorable Moment + const schemaMemorableMoment = { + type: 'object', + properties: { + headline: { type: 'string' }, + detail: { type: 'string' }, + }, + required: ['headline', 'detail'], + }; + + // 6. Improvements + const schemaImprovements = { + type: 'object', + properties: { + Qwen_md_additions: { + type: 'array', + items: { + type: 'object', + properties: { + addition: { type: 'string' }, + why: { type: 'string' }, + prompt_scaffold: { type: 'string' }, + }, + required: ['addition', 'why', 'prompt_scaffold'], + }, + }, + features_to_try: { + type: 'array', + items: { + type: 'object', + properties: { + feature: { type: 'string' }, + one_liner: { type: 'string' }, + why_for_you: { type: 'string' }, + example_code: { type: 'string' }, + }, + required: ['feature', 'one_liner', 'why_for_you', 'example_code'], + }, + }, + usage_patterns: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + suggestion: { type: 'string' }, + detail: { type: 'string' }, + copyable_prompt: { type: 'string' }, + }, + required: ['title', 'suggestion', 'detail', 'copyable_prompt'], + }, + }, + }, + required: ['Qwen_md_additions', 'features_to_try', 'usage_patterns'], + }; + + // 7. Interaction Style + const schemaInteractionStyle = { + type: 'object', + properties: { + narrative: { type: 'string' }, + key_pattern: { type: 'string' }, + }, + required: ['narrative', 'key_pattern'], + }; + + // 8. At A Glance + const schemaAtAGlance = { + type: 'object', + properties: { + whats_working: { type: 'string' }, + whats_hindering: { type: 'string' }, + quick_wins: { type: 'string' }, + ambitious_workflows: { type: 'string' }, + }, + required: [ + 'whats_working', + 'whats_hindering', + 'quick_wins', + 'ambitious_workflows', + ], + }; + + const limit = pLimit(CONCURRENCY_LIMIT); + + try { + const [ + impressiveWorkflows, + projectAreas, + futureOpportunities, + frictionPoints, + memorableMoment, + improvements, + interactionStyle, + atAGlance, + ] = await Promise.all([ + limit(() => + generate( + getInsightPrompt('impressive_workflows'), + schemaImpressiveWorkflows, + ), + ), + limit(() => + generate( + getInsightPrompt('project_areas'), + schemaProjectAreas, + ), + ), + limit(() => + generate( + getInsightPrompt('future_opportunities'), + schemaFutureOpportunities, + ), + ), + limit(() => + generate( + getInsightPrompt('friction_points'), + schemaFrictionPoints, + ), + ), + limit(() => + generate( + getInsightPrompt('memorable_moment'), + schemaMemorableMoment, + ), + ), + limit(() => + generate( + getInsightPrompt('improvements'), + schemaImprovements, + ), + ), + limit(() => + generate( + getInsightPrompt('interaction_style'), + schemaInteractionStyle, + ), + ), + limit(() => + generate( + getInsightPrompt('at_a_glance'), + schemaAtAGlance, + ), + ), + ]); + + logger.debug( + JSON.stringify( + { + impressiveWorkflows, + projectAreas, + futureOpportunities, + frictionPoints, + memorableMoment, + improvements, + interactionStyle, + atAGlance, + }, + null, + 2, + ), + ); + + return { + impressiveWorkflows, + projectAreas, + futureOpportunities, + frictionPoints, + memorableMoment, + improvements, + interactionStyle, + atAGlance, + }; + } catch (e) { + logger.error('Error generating qualitative insights:', e); + return undefined; + } + } + + private prepareCommonPromptData( + metrics: Omit, + facets: SessionFacets[], + ): string { + // 1. DATA section + const goalsAgg: Record = {}; + const outcomesAgg: Record = {}; + const satisfactionAgg: Record = {}; + const frictionAgg: Record = {}; + const successAgg: Record = {}; + + facets.forEach((facet) => { + // Aggregate goals + Object.entries(facet.goal_categories).forEach(([goal, count]) => { + goalsAgg[goal] = (goalsAgg[goal] || 0) + count; + }); + + // Aggregate outcomes + outcomesAgg[facet.outcome] = (outcomesAgg[facet.outcome] || 0) + 1; + + // Aggregate satisfaction + Object.entries(facet.user_satisfaction_counts).forEach(([sat, count]) => { + satisfactionAgg[sat] = (satisfactionAgg[sat] || 0) + count; + }); + + // Aggregate friction + Object.entries(facet.friction_counts).forEach(([fric, count]) => { + frictionAgg[fric] = (frictionAgg[fric] || 0) + count; + }); + + // Aggregate success (primary_success) + if (facet.primary_success && facet.primary_success !== 'none') { + successAgg[facet.primary_success] = + (successAgg[facet.primary_success] || 0) + 1; + } + }); + + const topGoals = Object.entries(goalsAgg) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8); + + const dataObj = { + sessions: metrics.totalSessions || facets.length, + analyzed: facets.length, + date_range: { + start: Object.keys(metrics.heatmap).sort()[0] || 'N/A', + end: Object.keys(metrics.heatmap).sort().pop() || 'N/A', + }, + messages: metrics.totalMessages || 0, + hours: metrics.totalHours || 0, + commits: 0, // Not tracked yet + top_tools: metrics.topTools || [], + top_goals: topGoals, + outcomes: outcomesAgg, + satisfaction: satisfactionAgg, + friction: frictionAgg, + success: successAgg, + }; + + // 2. SESSION SUMMARIES section + const sessionSummaries = facets + .map((f) => `- ${f.brief_summary}`) + .join('\n'); + + // 3. FRICTION DETAILS section + const frictionDetails = facets + .filter((f) => f.friction_detail && f.friction_detail.trim().length > 0) + .map((f) => `- ${f.friction_detail}`) + .join('\n'); + + return `DATA: +${JSON.stringify(dataObj, null, 2)} + +SESSION SUMMARIES: +${sessionSummaries} + +FRICTION DETAILS: +${frictionDetails} + +USER INSTRUCTIONS TO Qwen: +None captured`; + } + + private async scanChatFiles( + baseDir: string, + ): Promise> { + const allChatFiles: Array<{ path: string; mtime: number }> = []; + + try { + // Get all project directories in the base directory + const projectDirs = await fs.readdir(baseDir); + + // Process each project directory + for (const projectDir of projectDirs) { + const projectPath = path.join(baseDir, projectDir); + const stats = await fs.stat(projectPath); + + // Only process if it's a directory + if (stats.isDirectory()) { + const chatsDir = path.join(projectPath, 'chats'); + + try { + // Get all chat files in the chats directory + const files = await fs.readdir(chatsDir); + const chatFiles = files.filter((file) => file.endsWith('.jsonl')); + + for (const file of chatFiles) { + const filePath = path.join(chatsDir, file); + + // Get file stats for sorting by recency + try { + const fileStats = await fs.stat(filePath); + allChatFiles.push({ path: filePath, mtime: fileStats.mtimeMs }); + } catch (e) { + logger.error(`Failed to stat file ${filePath}:`, e); + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error( + `Error reading chats directory for project ${projectDir}: ${error}`, + ); + } + // Continue to next project if chats directory doesn't exist + continue; + } + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + // Base directory doesn't exist, return empty + logger.info(`Base directory does not exist: ${baseDir}`); + } else { + logger.error(`Error reading base directory: ${error}`); + } + } + + return allChatFiles; + } + + private async generateMetrics( + files: Array<{ path: string; mtime: number }>, + onProgress?: InsightProgressCallback, + ): Promise> { + // Initialize data structures + const heatmap: HeatMapData = {}; + const activeHours: { [hour: number]: number } = {}; + const sessionStartTimes: { [sessionId: string]: Date } = {}; + const sessionEndTimes: { [sessionId: string]: Date } = {}; + let totalMessages = 0; + let totalLinesAdded = 0; + let totalLinesRemoved = 0; + const uniqueFiles = new Set(); + const toolUsage: Record = {}; + + // Process files in batches to avoid OOM and blocking the event loop + const BATCH_SIZE = 50; + const totalFiles = files.length; + + for (let i = 0; i < totalFiles; i += BATCH_SIZE) { + const batchEnd = Math.min(i + BATCH_SIZE, totalFiles); + const batch = files.slice(i, batchEnd); + + // Process batch sequentially to minimize memory usage + for (const fileInfo of batch) { + try { + const records = await readJsonlFile(fileInfo.path); + + // Process each record + for (const record of records) { + const timestamp = new Date(record.timestamp); + const dateKey = this.formatDate(timestamp); + const hour = timestamp.getHours(); + + // Count user messages and slash commands (actual user interactions) + const isUserMessage = record.type === 'user'; + const isSlashCommand = + record.type === 'system' && record.subtype === 'slash_command'; + if (isUserMessage || isSlashCommand) { + totalMessages++; + + // Update heatmap (count of user interactions per day) + heatmap[dateKey] = (heatmap[dateKey] || 0) + 1; + + // Update active hours + activeHours[hour] = (activeHours[hour] || 0) + 1; + } + + // Track session times + if (!sessionStartTimes[record.sessionId]) { + sessionStartTimes[record.sessionId] = timestamp; + } + sessionEndTimes[record.sessionId] = timestamp; + + // Track tool usage + if (record.type === 'assistant' && record.message?.parts) { + for (const part of record.message.parts) { + if ('functionCall' in part) { + const name = part.functionCall!.name!; + toolUsage[name] = (toolUsage[name] || 0) + 1; + } + } + } + + // Track lines and files from tool results + if ( + record.type === 'tool_result' && + record.toolCallResult?.resultDisplay + ) { + const display = record.toolCallResult.resultDisplay; + // Check if it matches FileDiff shape + if ( + typeof display === 'object' && + display !== null && + 'fileName' in display + ) { + // Cast to any to avoid importing FileDiff type which might not be available here + const diff = display as { + fileName: unknown; + diffStat?: { + model_added_lines?: number; + model_removed_lines?: number; + }; + }; + if (typeof diff.fileName === 'string') { + uniqueFiles.add(diff.fileName); + } + + if (diff.diffStat) { + totalLinesAdded += diff.diffStat.model_added_lines || 0; + totalLinesRemoved += diff.diffStat.model_removed_lines || 0; + } + } + } + } + } catch (error) { + logger.error( + `Failed to process metrics for file ${fileInfo.path}:`, + error, + ); + // Continue to next file + } + } + + // Update progress (mapped to 10-20% range of total progress) + if (onProgress) { + const percentComplete = batchEnd / totalFiles; + const overallProgress = 10 + Math.round(percentComplete * 10); + onProgress( + `Crunching the numbers (${batchEnd}/${totalFiles})`, + overallProgress, + ); + } + + // Yield to event loop to allow GC and UI updates + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + // Calculate streak data + const streakData = this.calculateStreaks(Object.keys(heatmap)); + + // Calculate longest work session and total hours + let longestWorkDuration = 0; + let longestWorkDate: string | null = null; + let totalDurationMs = 0; + + const sessionIds = Object.keys(sessionStartTimes); + const totalSessions = sessionIds.length; + + for (const sessionId of sessionIds) { + const start = sessionStartTimes[sessionId]; + const end = sessionEndTimes[sessionId]; + const durationMs = end.getTime() - start.getTime(); + const durationMinutes = Math.round(durationMs / (1000 * 60)); + + totalDurationMs += durationMs; + + if (durationMinutes > longestWorkDuration) { + longestWorkDuration = durationMinutes; + longestWorkDate = this.formatDate(start); + } + } + + const totalHours = Math.round(totalDurationMs / (1000 * 60 * 60)); + + // Calculate latest active time + let latestActiveTime: string | null = null; + let latestTimestamp = new Date(0); + for (const dateStr in heatmap) { + const date = new Date(dateStr); + if (date > latestTimestamp) { + latestTimestamp = date; + latestActiveTime = date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + } + } + + // Calculate top tools + const topTools = Object.entries(toolUsage) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + + return { + heatmap, + currentStreak: streakData.currentStreak, + longestStreak: streakData.longestStreak, + longestWorkDate, + longestWorkDuration, + activeHours, + latestActiveTime, + totalSessions, + totalMessages, + totalHours, + topTools, + totalLinesAdded, + totalLinesRemoved, + totalFiles: uniqueFiles.size, + }; + } + + private async generateFacets( + allFiles: Array<{ path: string; mtime: number }>, + facetsOutputDir?: string, + onProgress?: InsightProgressCallback, + ): Promise { + const MAX_ELIGIBLE_SESSIONS = 50; + + // Sort files by recency (descending), then select up to 50 conversational + // sessions (must contain both user and assistant records). + const sortedFiles = [...allFiles].sort((a, b) => b.mtime - a.mtime); + const eligibleSessions: Array<{ + fileInfo: { path: string; mtime: number }; + records: ChatRecord[]; + }> = []; + + for (const fileInfo of sortedFiles) { + if (eligibleSessions.length >= MAX_ELIGIBLE_SESSIONS) { + break; + } + + try { + const records = await readJsonlFile(fileInfo.path); + if (!this.hasUserAndAssistantRecords(records)) { + continue; + } + eligibleSessions.push({ fileInfo, records }); + } catch (e) { + logger.error( + `Error reading session file ${fileInfo.path} for facet eligibility:`, + e, + ); + } + } + + logger.info( + `Analyzing ${eligibleSessions.length} eligible recent sessions with LLM...`, + ); + + // Create a limit function with concurrency of 4 to avoid 429 errors + const limit = pLimit(CONCURRENCY_LIMIT); + + let completed = 0; + const total = eligibleSessions.length; + + // Analyze sessions concurrently with limit + const analysisPromises = eligibleSessions.map(({ fileInfo, records }) => + limit(async () => { + try { + // Check if we already have this session analyzed + if (records.length > 0 && facetsOutputDir) { + const sessionId = records[0].sessionId; + if (sessionId) { + const existingFacetPath = path.join( + facetsOutputDir, + `${sessionId}.json`, + ); + try { + // Check if file exists and is readable + const existingData = await fs.readFile( + existingFacetPath, + 'utf-8', + ); + const existingFacet = JSON.parse(existingData); + completed++; + if (onProgress) { + const percent = 20 + Math.round((completed / total) * 60); + onProgress( + 'Analyzing sessions', + percent, + `${completed}/${total}`, + ); + } + return existingFacet; + } catch (readError) { + // File doesn't exist or is invalid, proceed to analyze + if ((readError as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn( + `Failed to read existing facet for ${sessionId}, regenerating:`, + readError, + ); + } + } + } + } + + const facet = await this.analyzeSession(records); + + if (facet && facetsOutputDir) { + try { + const facetPath = path.join( + facetsOutputDir, + `${facet.session_id}.json`, + ); + await fs.writeFile( + facetPath, + JSON.stringify(facet, null, 2), + 'utf-8', + ); + } catch (writeError) { + logger.error( + `Failed to write facet file for session ${facet.session_id}:`, + writeError, + ); + } + } + + completed++; + if (onProgress) { + const percent = 20 + Math.round((completed / total) * 60); + onProgress('Analyzing sessions', percent, `${completed}/${total}`); + } + + return facet; + } catch (e) { + logger.error(`Error analyzing session file ${fileInfo.path}:`, e); + completed++; + if (onProgress) { + const percent = 20 + Math.round((completed / total) * 60); + onProgress('Analyzing sessions', percent, `${completed}/${total}`); + } + return null; + } + }), + ); + + const sessionFacetsWithNulls = await Promise.all(analysisPromises); + const facets = sessionFacetsWithNulls.filter( + (f): f is SessionFacets => f !== null, + ); + return facets; + } +} diff --git a/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts b/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts new file mode 100644 index 000000000..99bcb9e26 --- /dev/null +++ b/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { DataProcessor } from './DataProcessor.js'; +import { TemplateRenderer } from './TemplateRenderer.js'; +import type { + InsightData, + InsightProgressCallback, +} from '../types/StaticInsightTypes.js'; + +import { createDebugLogger, type Config } from '@qwen-code/qwen-code-core'; + +const logger = createDebugLogger('StaticInsightGenerator'); + +export class StaticInsightGenerator { + private dataProcessor: DataProcessor; + private templateRenderer: TemplateRenderer; + + constructor(config: Config) { + this.dataProcessor = new DataProcessor(config); + this.templateRenderer = new TemplateRenderer(); + } + + // Ensure the output directory exists + private async ensureOutputDirectory(): Promise { + const outputDir = path.join(os.homedir(), '.qwen', 'insights'); + await fs.mkdir(outputDir, { recursive: true }); + return outputDir; + } + + // Generate timestamped filename with collision detection + private async generateOutputPath(outputDir: string): Promise { + const now = new Date(); + const date = now.toISOString().split('T')[0]; // YYYY-MM-DD + const time = now.toTimeString().slice(0, 8).replace(/:/g, ''); // HHMMSS + + let outputPath = path.join(outputDir, `insight-${date}.html`); + + // Check if date-only file exists, if so, add timestamp + try { + await fs.access(outputPath); + // File exists, use timestamped version + outputPath = path.join(outputDir, `insight-${date}-${time}.html`); + } catch { + // File doesn't exist, use date-only name + } + + return outputPath; + } + + // Create or update the "latest" alias (symlink preferred, copy as fallback) + private async updateLatestAlias( + outputDir: string, + targetPath: string, + ): Promise { + const latestPath = path.join(outputDir, 'insight.html'); + const relativeTarget = path.relative(outputDir, targetPath); + + // Remove existing file/symlink if it exists + try { + await fs.unlink(latestPath); + } catch { + // File doesn't exist, ignore + } + + // Try symlink first (preferred - lightweight, always points to latest) + try { + await fs.symlink(relativeTarget, latestPath); + logger.debug('Created insight symlink:', relativeTarget); + return; + } catch (error) { + logger.debug( + 'Failed to create insight symlink, falling back to copy:', + error, + ); + } + + // Fallback: copy file (works everywhere, uses more disk space) + try { + await fs.copyFile(targetPath, latestPath); + logger.debug('Created insight copy:', targetPath); + } catch (error) { + logger.debug('Failed to create insight latest alias:', error); + } + } + + // Generate the static insight HTML file + async generateStaticInsight( + baseDir: string, + onProgress?: InsightProgressCallback, + ): Promise { + // Ensure output directory exists + const outputDir = await this.ensureOutputDirectory(); + const facetsDir = path.join(outputDir, 'facets'); + await fs.mkdir(facetsDir, { recursive: true }); + + // Process data + const insights: InsightData = await this.dataProcessor.generateInsights( + baseDir, + facetsDir, + onProgress, + ); + + // Render HTML + const html = await this.templateRenderer.renderInsightHTML(insights); + + // Generate timestamped output path + const outputPath = await this.generateOutputPath(outputDir); + + // Write the HTML file + await fs.writeFile(outputPath, html, 'utf-8'); + + // Update latest alias (symlink preferred, copy as fallback) + await this.updateLatestAlias(outputDir, outputPath); + + return outputPath; + } +} diff --git a/packages/cli/src/services/insight/generators/TemplateRenderer.ts b/packages/cli/src/services/insight/generators/TemplateRenderer.ts new file mode 100644 index 000000000..8b6f779f7 --- /dev/null +++ b/packages/cli/src/services/insight/generators/TemplateRenderer.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { INSIGHT_JS, INSIGHT_CSS } from '@qwen-code/web-templates'; +import type { InsightData } from '../types/StaticInsightTypes.js'; + +export class TemplateRenderer { + // Render the complete HTML file + async renderInsightHTML(insights: InsightData): Promise { + const html = ` + + + + + Qwen Code Insights + + + +
+
+
+
+
+ + + + + + + + + + + + + + + +`; + + return html; + } +} diff --git a/packages/cli/src/services/insight/types/QualitativeInsightTypes.ts b/packages/cli/src/services/insight/types/QualitativeInsightTypes.ts new file mode 100644 index 000000000..fc9546b98 --- /dev/null +++ b/packages/cli/src/services/insight/types/QualitativeInsightTypes.ts @@ -0,0 +1,82 @@ +export interface InsightImpressiveWorkflows { + intro: string; + impressive_workflows: Array<{ + title: string; + description: string; + }>; +} + +export interface InsightProjectAreas { + areas: Array<{ + name: string; + session_count: number; + description: string; + }>; +} + +export interface InsightFutureOpportunities { + intro: string; + opportunities: Array<{ + title: string; + whats_possible: string; + how_to_try: string; + copyable_prompt: string; + }>; +} + +export interface InsightFrictionPoints { + intro: string; + categories: Array<{ + category: string; + description: string; + examples: string[]; + }>; +} + +export interface InsightMemorableMoment { + headline: string; + detail: string; +} + +export interface InsightImprovements { + Qwen_md_additions: Array<{ + addition: string; + why: string; + prompt_scaffold: string; + }>; + features_to_try: Array<{ + feature: string; + one_liner: string; + why_for_you: string; + example_code: string; + }>; + usage_patterns: Array<{ + title: string; + suggestion: string; + detail: string; + copyable_prompt: string; + }>; +} + +export interface InsightInteractionStyle { + narrative: string; + key_pattern: string; +} + +export interface InsightAtAGlance { + whats_working: string; + whats_hindering: string; + quick_wins: string; + ambitious_workflows: string; +} + +export interface QualitativeInsights { + impressiveWorkflows: InsightImpressiveWorkflows; + projectAreas: InsightProjectAreas; + futureOpportunities: InsightFutureOpportunities; + frictionPoints: InsightFrictionPoints; + memorableMoment: InsightMemorableMoment; + improvements: InsightImprovements; + interactionStyle: InsightInteractionStyle; + atAGlance: InsightAtAGlance; +} diff --git a/packages/cli/src/services/insight/types/StaticInsightTypes.ts b/packages/cli/src/services/insight/types/StaticInsightTypes.ts new file mode 100644 index 000000000..29ce39f16 --- /dev/null +++ b/packages/cli/src/services/insight/types/StaticInsightTypes.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { QualitativeInsights } from './QualitativeInsightTypes.js'; + +export interface HeatMapData { + [date: string]: number; +} + +export interface InsightData { + heatmap: HeatMapData; + currentStreak: number; + longestStreak: number; + longestWorkDate: string | null; + longestWorkDuration: number; // in minutes + activeHours: { [hour: number]: number }; + latestActiveTime: string | null; + totalSessions?: number; + totalMessages?: number; + totalHours?: number; + totalLinesAdded?: number; + totalLinesRemoved?: number; + totalFiles?: number; + topTools?: Array<[string, number]>; + qualitative?: QualitativeInsights; + satisfaction?: Record; + friction?: Record; + primarySuccess?: Record; + outcomes?: Record; + topGoals?: Record; +} + +export interface StreakData { + currentStreak: number; + longestStreak: number; + dates: string[]; +} + +export interface SessionFacets { + session_id: string; + underlying_goal: string; + goal_categories: Record; + outcome: + | 'fully_achieved' + | 'mostly_achieved' + | 'partially_achieved' + | 'not_achieved' + | 'unclear_from_transcript'; + user_satisfaction_counts: Record; + Qwen_helpfulness: + | 'unhelpful' + | 'slightly_helpful' + | 'moderately_helpful' + | 'very_helpful' + | 'essential'; + session_type: + | 'single_task' + | 'multi_task' + | 'iterative_refinement' + | 'exploration' + | 'quick_question'; + friction_counts: Record; + friction_detail: string; + primary_success: + | 'none' + | 'fast_accurate_search' + | 'correct_code_edits' + | 'good_explanations' + | 'proactive_help' + | 'multi_file_changes' + | 'good_debugging'; + brief_summary: string; +} + +export interface StaticInsightTemplateData { + styles: string; + content: string; + data: InsightData; + scripts: string; + generatedTime: string; +} + +export type InsightProgressCallback = ( + stage: string, + progress: number, + detail?: string, +) => void; diff --git a/packages/cli/src/services/markdown-command-parser.test.ts b/packages/cli/src/services/markdown-command-parser.test.ts index 4de35f0ea..bbefa43a4 100644 --- a/packages/cli/src/services/markdown-command-parser.test.ts +++ b/packages/cli/src/services/markdown-command-parser.test.ts @@ -94,6 +94,51 @@ Prompt content.`; expect(result.frontmatter).toBeDefined(); expect(result.prompt).toBe('Prompt content.'); }); + + it('should parse frontmatter in CRLF files', () => { + const content = + '---\r\ndescription: Windows command\r\n---\r\n\r\nLine 1\r\nLine 2\r\n'; + + const result = parseMarkdownCommand(content); + + expect(result).toEqual({ + frontmatter: { + description: 'Windows command', + }, + prompt: 'Line 1\nLine 2', + }); + }); + + it('should parse frontmatter in CR-only files', () => { + const content = + '---\rdescription: Old mac command\r---\r\rLine 1\rLine 2\r'; + + const result = parseMarkdownCommand(content); + + expect(result).toEqual({ + frontmatter: { + description: 'Old mac command', + }, + prompt: 'Line 1\nLine 2', + }); + }); + + it('should parse frontmatter when content starts with UTF-8 BOM', () => { + const content = `\uFEFF--- +description: BOM command +--- + +Prompt from BOM file.`; + + const result = parseMarkdownCommand(content); + + expect(result).toEqual({ + frontmatter: { + description: 'BOM command', + }, + prompt: 'Prompt from BOM file.', + }); + }); }); describe('MarkdownCommandDefSchema', () => { diff --git a/packages/cli/src/services/markdown-command-parser.ts b/packages/cli/src/services/markdown-command-parser.ts index 5b6ed38bf..5d4a3b7df 100644 --- a/packages/cli/src/services/markdown-command-parser.ts +++ b/packages/cli/src/services/markdown-command-parser.ts @@ -5,7 +5,10 @@ */ import { z } from 'zod'; -import { parse as parseYaml } from '@qwen-code/qwen-code-core'; +import { + parse as parseYaml, + normalizeContent, +} from '@qwen-code/qwen-code-core'; /** * Defines the Zod schema for a Markdown command definition file. @@ -31,19 +34,21 @@ export type MarkdownCommandDef = z.infer; * @returns Parsed command definition with frontmatter and prompt */ export function parseMarkdownCommand(content: string): MarkdownCommandDef { + const normalizedContent = normalizeContent(content); + // Match YAML frontmatter pattern: ---\n...\n---\n - // Allow empty frontmatter: ---\n---\n // Use (?:[\s\S]*?) to make the frontmatter content optional - const frontmatterRegex = /^---\n([\s\S]*?)---\n([\s\S]*)$/; - const match = content.match(frontmatterRegex); + // Allow empty frontmatter: ---\n---\n + const frontmatterRegex = /^---\n(?:([\s\S]*?)\n)?---(?:\n|$)([\s\S]*)$/; + const match = normalizedContent.match(frontmatterRegex); if (!match) { // No frontmatter, entire content is the prompt return { - prompt: content.trim(), + prompt: normalizedContent.trim(), }; } - const [, frontmatterYaml, body] = match; + const [, frontmatterYaml = '', body] = match; // Parse YAML frontmatter if not empty let frontmatter: Record | undefined; diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 1edec79f9..9e9d4f673 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -209,6 +209,7 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], thought: null, cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), }); mockedUseVim.mockReturnValue({ handleInput: vi.fn() }); mockedUseFolderTrust.mockReturnValue({ @@ -607,6 +608,7 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], thought: { subject: thoughtSubject }, cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), }); // Act: Render the container @@ -652,6 +654,7 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], thought: null, cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), }); // Act: Render the container @@ -698,6 +701,7 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], thought: { subject: thoughtSubject }, cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), }); // Act: Render the container @@ -744,6 +748,7 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], thought: { subject: shortTitle }, cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), }); // Act: Render the container @@ -794,6 +799,7 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], thought: { subject: title }, cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), }); // Act: Render the container @@ -841,6 +847,7 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], thought: null, cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), }); // Act: Render the container @@ -882,6 +889,7 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], thought: null, cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), activePtyId: 'some-id', }); @@ -1013,6 +1021,7 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], thought: null, cancelOngoingRequest: mockCancelOngoingRequest, + retryLastPrompt: vi.fn(), }); const mockHandleSlashCommand = vi.fn(); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c438a527d..7acc11b15 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -100,8 +100,6 @@ import { t } from '../i18n/index.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useDialogClose } from './hooks/useDialogClose.js'; import { useInitializationAuthError } from './hooks/useInitializationAuthError.js'; -import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js'; -import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; import { useMcpDialog } from './hooks/useMcpDialog.js'; @@ -498,18 +496,6 @@ export const AppContainer = (props: AppContainerProps) => { } = useAgentsManagerDialog(); const { isMcpDialogOpen, openMcpDialog, closeMcpDialog } = useMcpDialog(); - // Vision model auto-switch dialog state (must be before slashCommandActions) - const [isVisionSwitchDialogOpen, setIsVisionSwitchDialogOpen] = - useState(false); - const [visionSwitchResolver, setVisionSwitchResolver] = useState<{ - resolve: (result: { - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }) => void; - reject: () => void; - } | null>(null); - const slashCommandActions = useMemo( () => ({ openAuthDialog, @@ -567,6 +553,7 @@ export const AppContainer = (props: AppContainerProps) => { historyManager.loadHistory, refreshStatic, toggleVimEnabled, + isProcessing, setIsProcessing, setGeminiMdFileCount, slashCommandActions, @@ -575,32 +562,6 @@ export const AppContainer = (props: AppContainerProps) => { logger, ); - // Vision switch handlers - const handleVisionSwitchRequired = useCallback( - async (_query: unknown) => - new Promise<{ - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }>((resolve, reject) => { - setVisionSwitchResolver({ resolve, reject }); - setIsVisionSwitchDialogOpen(true); - }), - [], - ); - - const handleVisionSwitchSelect = useCallback( - (outcome: VisionSwitchOutcome) => { - setIsVisionSwitchDialogOpen(false); - if (visionSwitchResolver) { - const result = processVisionSwitchOutcome(outcome); - visionSwitchResolver.resolve(result); - setVisionSwitchResolver(null); - } - }, - [visionSwitchResolver], - ); - // onDebugMessage should log to debug logfile, not update footer debugMessage const onDebugMessage = useCallback( (message: string) => { @@ -672,6 +633,7 @@ export const AppContainer = (props: AppContainerProps) => { pendingHistoryItems: pendingGeminiHistoryItems, thought, cancelOngoingRequest, + retryLastPrompt, handleApprovalModeChange, activePtyId, loopDetectionConfirmationRequest, @@ -691,11 +653,9 @@ export const AppContainer = (props: AppContainerProps) => { setModelSwitchedFromQuotaError, refreshStatic, () => cancelHandlerRef.current(), - settings.merged.experimental?.visionModelPreview ?? false, // visionModelPreviewEnabled setEmbeddedShellFocused, terminalWidth, terminalHeight, - handleVisionSwitchRequired, // onVisionSwitchRequired ); // Track whether suggestions are visible for Tab key handling @@ -850,7 +810,6 @@ export const AppContainer = (props: AppContainerProps) => { !isThemeDialogOpen && !isEditorDialogOpen && !showWelcomeBackDialog && - !isVisionSwitchDialogOpen && welcomeBackChoice !== 'restart' && geminiClient?.isInitialized?.() ) { @@ -866,7 +825,6 @@ export const AppContainer = (props: AppContainerProps) => { isThemeDialogOpen, isEditorDialogOpen, showWelcomeBackDialog, - isVisionSwitchDialogOpen, welcomeBackChoice, geminiClient, ]); @@ -1338,7 +1296,6 @@ export const AppContainer = (props: AppContainerProps) => { isThemeDialogOpen || isSettingsDialogOpen || isModelDialogOpen || - isVisionSwitchDialogOpen || isPermissionsDialogOpen || isAuthDialogOpen || isAuthenticating || @@ -1451,8 +1408,6 @@ export const AppContainer = (props: AppContainerProps) => { extensionsUpdateState, activePtyId, embeddedShellFocused, - // Vision switch dialog - isVisionSwitchDialogOpen, // Welcome back dialog showWelcomeBackDialog, welcomeBackInfo, @@ -1545,8 +1500,6 @@ export const AppContainer = (props: AppContainerProps) => { activePtyId, historyManager, embeddedShellFocused, - // Vision switch dialog - isVisionSwitchDialogOpen, // Welcome back dialog showWelcomeBackDialog, welcomeBackInfo, @@ -1589,9 +1542,8 @@ export const AppContainer = (props: AppContainerProps) => { onSuggestionsVisibilityChange: setHasSuggestionsVisible, refreshStatic, handleFinalSubmit, + handleRetryLastPrompt: retryLastPrompt, handleClearScreen, - // Vision switch dialog - handleVisionSwitchSelect, // Welcome back dialog handleWelcomeBackSelection, handleWelcomeBackClose, @@ -1636,8 +1588,8 @@ export const AppContainer = (props: AppContainerProps) => { handleEscapePromptChange, refreshStatic, handleFinalSubmit, + retryLastPrompt, handleClearScreen, - handleVisionSwitchSelect, handleWelcomeBackSelection, handleWelcomeBackClose, // Subagent dialogs diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index a975a599e..90b15c968 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -32,6 +32,7 @@ const createMockUIActions = (overrides: Partial = {}): UIActions => { // AuthDialog only uses handleAuthSelect const baseActions = { handleAuthSelect: vi.fn(), + handleRetryLastPrompt: vi.fn(), } as Partial; return { @@ -169,9 +170,9 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); - // Since the auth dialog shows API-KEY option now, + // Since the auth dialog shows API Key option now, // it won't show GEMINI_API_KEY messages - expect(lastFrame()).toContain('API-KEY'); + expect(lastFrame()).toContain('API Key'); }); it('should not show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to something else', () => { @@ -257,9 +258,9 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); - // Since the auth dialog shows API-KEY option now, + // Since the auth dialog shows API Key option now, // it won't show GEMINI_API_KEY messages - expect(lastFrame()).toContain('API-KEY'); + expect(lastFrame()).toContain('API Key'); }); }); @@ -305,7 +306,7 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); // QWEN_OAUTH is the first option, so it should be selected - expect(lastFrame()).toContain('● 1. Qwen OAuth'); + expect(lastFrame()).toContain('Qwen OAuth'); }); it('should fall back to default if QWEN_DEFAULT_AUTH_TYPE is not set', () => { @@ -345,7 +346,7 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); // Default is Qwen OAuth (first option) - expect(lastFrame()).toContain('● 1. Qwen OAuth'); + expect(lastFrame()).toContain('Qwen OAuth'); }); it('should show an error and fall back to default if QWEN_DEFAULT_AUTH_TYPE is invalid', () => { @@ -388,7 +389,7 @@ describe('AuthDialog', () => { // Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore, // it will just show the default Qwen OAuth option - expect(lastFrame()).toContain('● 1. Qwen OAuth'); + expect(lastFrame()).toContain('Qwen OAuth'); }); }); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 7f43fa582..309e77adf 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -11,16 +11,19 @@ import { Box, Text } from 'ink'; import Link from 'ink-link'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { DescriptiveRadioButtonSelect } from '../components/shared/DescriptiveRadioButtonSelect.js'; import { ApiKeyInput } from '../components/ApiKeyInput.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { t } from '../../i18n/index.js'; -import { CodingPlanRegion } from '../../constants/codingPlan.js'; +import { + CodingPlanRegion, + isCodingPlanConfig, +} from '../../constants/codingPlan.js'; const MODEL_PROVIDERS_DOCUMENTATION_URL = - 'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders'; + 'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/'; function parseDefaultAuthType( defaultAuthType: string | undefined, @@ -34,11 +37,11 @@ function parseDefaultAuthType( return null; } -// Sub-mode types for API-KEY authentication -type ApiKeySubMode = 'coding-plan' | 'coding-plan-intl' | 'custom'; +// Main menu option type +type MainOption = typeof AuthType.QWEN_OAUTH | 'CODING_PLAN' | 'API_KEY'; // View level for navigation -type ViewLevel = 'main' | 'api-key-sub' | 'api-key-input' | 'custom-info'; +type ViewLevel = 'main' | 'region-select' | 'api-key-input' | 'custom-info'; export function AuthDialog(): React.JSX.Element { const { pendingAuthType, authError } = useUIState(); @@ -50,58 +53,107 @@ export function AuthDialog(): React.JSX.Element { const config = useConfig(); const [errorMessage, setErrorMessage] = useState(null); - const [selectedIndex, setSelectedIndex] = useState(null); const [viewLevel, setViewLevel] = useState('main'); - const [apiKeySubModeIndex, setApiKeySubModeIndex] = useState(0); + const [regionIndex, setRegionIndex] = useState(0); const [region, setRegion] = useState( CodingPlanRegion.CHINA, ); - // Main authentication entries + // Main authentication entries (flat three-option layout) const mainItems = [ { key: AuthType.QWEN_OAUTH, + title: t('Qwen OAuth'), label: t('Qwen OAuth'), - value: AuthType.QWEN_OAUTH, + description: t( + 'Free \u00B7 Up to 1,000 requests/day \u00B7 Qwen latest models', + ), + value: AuthType.QWEN_OAUTH as MainOption, }, { - key: 'API-KEY', - label: t('API-KEY'), - value: 'API-KEY' as const, + key: 'CODING_PLAN', + title: t('Alibaba Cloud Coding Plan'), + label: t('Alibaba Cloud Coding Plan'), + description: t( + 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models', + ), + value: 'CODING_PLAN' as MainOption, + }, + { + key: 'API_KEY', + title: t('API Key'), + label: t('API Key'), + description: t('Bring your own API key'), + value: 'API_KEY' as MainOption, }, ]; - // API-KEY sub-mode entries - const apiKeySubItems = [ + // Region selection entries (shown after selecting Alibaba Cloud Coding Plan) + const regionItems = [ { - key: 'coding-plan', - label: t('Coding Plan (Bailian, China)'), - value: 'coding-plan' as ApiKeySubMode, + key: 'china', + title: '阿里云百炼 (aliyun.com)', + label: '阿里云百炼 (aliyun.com)', + description: ( + + + https://help.aliyun.com/zh/model-studio/coding-plan + + + ), + value: CodingPlanRegion.CHINA, }, { - key: 'coding-plan-intl', - label: t('Coding Plan (Bailian, Global/Intl)'), - value: 'coding-plan-intl' as ApiKeySubMode, - }, - { - key: 'custom', - label: t('Custom'), - value: 'custom' as ApiKeySubMode, + key: 'global', + title: 'Alibaba Cloud (alibabacloud.com)', + label: 'Alibaba Cloud (alibabacloud.com)', + description: ( + + + https://www.alibabacloud.com/help/en/model-studio/coding-plan + + + ), + value: CodingPlanRegion.GLOBAL, }, ]; + // Map an AuthType to the corresponding main menu option. + // QWEN_OAUTH maps directly; any other auth type maps to CODING_PLAN only + // if the current config actually uses a Coding Plan baseUrl+envKey, + // otherwise it maps to API_KEY. + const contentGenConfig = config.getContentGeneratorConfig(); + const isCurrentlyCodingPlan = + isCodingPlanConfig( + contentGenConfig?.baseUrl, + contentGenConfig?.apiKeyEnvKey, + ) !== false; + + const authTypeToMainOption = (authType: AuthType): MainOption => { + if (authType === AuthType.QWEN_OAUTH) return AuthType.QWEN_OAUTH; + if (authType === AuthType.USE_OPENAI && isCurrentlyCodingPlan) + return 'CODING_PLAN'; + return 'API_KEY'; + }; + const initialAuthIndex = Math.max( 0, mainItems.findIndex((item) => { // Priority 1: pendingAuthType if (pendingAuthType) { - return item.value === pendingAuthType; + return item.value === authTypeToMainOption(pendingAuthType); } // Priority 2: config.getAuthType() - the source of truth const currentAuthType = config.getAuthType(); if (currentAuthType) { - return item.value === currentAuthType; + return item.value === authTypeToMainOption(currentAuthType); } // Priority 3: QWEN_DEFAULT_AUTH_TYPE env var @@ -109,7 +161,7 @@ export function AuthDialog(): React.JSX.Element { process.env['QWEN_DEFAULT_AUTH_TYPE'], ); if (defaultAuthType) { - return item.value === defaultAuthType; + return item.value === authTypeToMainOption(defaultAuthType); } // Priority 4: default to QWEN_OAUTH @@ -117,21 +169,19 @@ export function AuthDialog(): React.JSX.Element { }), ); - const hasApiKey = Boolean(config.getContentGeneratorConfig()?.apiKey); - const currentSelectedAuthType = - selectedIndex !== null - ? mainItems[selectedIndex]?.value - : mainItems[initialAuthIndex]?.value; - - const handleMainSelect = async ( - value: (typeof mainItems)[number]['value'], - ) => { + const handleMainSelect = async (value: MainOption) => { setErrorMessage(null); onAuthError(null); - if (value === 'API-KEY') { - // Navigate to API-KEY sub-mode selection - setViewLevel('api-key-sub'); + if (value === 'CODING_PLAN') { + // Navigate to region selection + setViewLevel('region-select'); + return; + } + + if (value === 'API_KEY') { + // Navigate directly to custom API key info + setViewLevel('custom-info'); return; } @@ -139,19 +189,11 @@ export function AuthDialog(): React.JSX.Element { await onAuthSelect(value); }; - const handleApiKeySubSelect = async (subMode: ApiKeySubMode) => { + const handleRegionSelect = async (selectedRegion: CodingPlanRegion) => { setErrorMessage(null); onAuthError(null); - - if (subMode === 'coding-plan') { - setRegion(CodingPlanRegion.CHINA); - setViewLevel('api-key-input'); - } else if (subMode === 'coding-plan-intl') { - setRegion(CodingPlanRegion.GLOBAL); - setViewLevel('api-key-input'); - } else { - setViewLevel('custom-info'); - } + setRegion(selectedRegion); + setViewLevel('api-key-input'); }; const handleApiKeyInputSubmit = async (apiKey: string) => { @@ -170,12 +212,10 @@ export function AuthDialog(): React.JSX.Element { setErrorMessage(null); onAuthError(null); - if (viewLevel === 'api-key-sub') { + if (viewLevel === 'region-select' || viewLevel === 'custom-info') { setViewLevel('main'); - // Reset selectedIndex to ensure UI syncs with initialAuthIndex - setSelectedIndex(null); - } else if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') { - setViewLevel('api-key-sub'); + } else if (viewLevel === 'api-key-input') { + setViewLevel('region-select'); } }; @@ -183,7 +223,7 @@ export function AuthDialog(): React.JSX.Element { (key) => { if (key.name === 'escape') { // Handle Escape based on current view level - if (viewLevel === 'api-key-sub') { + if (viewLevel === 'region-select') { handleGoBack(); return; } @@ -215,62 +255,39 @@ export function AuthDialog(): React.JSX.Element { const renderMainView = () => ( <> - {t('How would you like to authenticate for this project?')} - - - { - const index = mainItems.findIndex((item) => item.value === value); - setSelectedIndex(index); - }} + itemGap={1} /> - - - {currentSelectedAuthType === AuthType.QWEN_OAUTH - ? t('Login with QwenChat account to use daily free quota.') - : t('Use coding plan credentials or your own api-keys/providers.')} - - ); - // Render API-KEY sub-mode selection - const renderApiKeySubView = () => ( + // Render region selection for Alibaba Cloud Coding Plan + const renderRegionSelectView = () => ( <> - {t('Select API-KEY configuration mode:')} - - - { - const index = apiKeySubItems.findIndex( - (item) => item.value === value, - ); - setApiKeySubModeIndex(index); - }} - /> - - - - {apiKeySubItems[apiKeySubModeIndex]?.value === 'custom' - ? t( - 'More instructions about configuring `modelProviders` manually.', - ) - : t( - "Paste your api key of Bailian Coding Plan and you're all set!", - )} + + {t('Choose based on where your account is registered')} + + { + const index = regionItems.findIndex((item) => item.value === value); + setRegionIndex(index); + }} + itemGap={1} + /> + - {t('(Press Escape to go back)')} + {t('Enter to select, ↑↓ to navigate, Esc to go back')} @@ -291,68 +308,22 @@ export function AuthDialog(): React.JSX.Element { const renderCustomInfoView = () => ( <> - {t('Custom API-KEY Configuration')} - - - - {t('For advanced users who want to configure models manually.')} + + {t('You can configure your API key and models in settings.json')} - {t('Please configure your models in settings.json:')} - - - - 1. {t('Set API key via environment variable (e.g., OPENAI_API_KEY)')} - - - - - 2.{' '} - {t( - "Add model configuration to modelProviders['openai'] (or other auth types)", - )} - - - - - 3.{' '} - {t( - 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig', - )} - - - - - 4.{' '} - {t( - 'Use /model command to select your preferred model from the configured list', - )} - - - - - {t( - 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.', - )} - - - - - {t('More instructions please check:')} - + {t('Refer to the documentation for setup instructions')} - + {MODEL_PROVIDERS_DOCUMENTATION_URL} - - {t('(Press Escape to go back)')} - + {t('Esc to go back')} ); @@ -360,15 +331,15 @@ export function AuthDialog(): React.JSX.Element { const getViewTitle = () => { switch (viewLevel) { case 'main': - return t('Get started'); - case 'api-key-sub': - return t('API-KEY Configuration'); + return t('Select Authentication Method'); + case 'region-select': + return t('Select Region for Coding Plan'); case 'api-key-input': - return t('Coding Plan Setup'); + return t('Enter Coding Plan API Key'); case 'custom-info': return t('Custom Configuration'); default: - return t('Get started'); + return t('Select Authentication Method'); } }; @@ -383,7 +354,7 @@ export function AuthDialog(): React.JSX.Element { {getViewTitle()} {viewLevel === 'main' && renderMainView()} - {viewLevel === 'api-key-sub' && renderApiKeySubView()} + {viewLevel === 'region-select' && renderRegionSelectView()} {viewLevel === 'api-key-input' && renderApiKeyInputView()} {viewLevel === 'custom-info' && renderCustomInfoView()} @@ -395,31 +366,28 @@ export function AuthDialog(): React.JSX.Element { {viewLevel === 'main' && ( <> - - - {t('(Use Enter to Set Auth)')} + {/* + + {t('Enter to select, \u2191\u2193 to navigate, Esc to close')} + + */} + + {'\u2500'.repeat(80)} + + + + {t('Terms of Services and Privacy Notice')}: - {hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && ( - - - {t( - '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.', - )} + + + + https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/ - - )} - - - {t('Terms of Services and Privacy Notice for Qwen Code')} - - - - - { - 'https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/' - } - + )} diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index bb05172aa..24cfbf61c 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -35,6 +35,7 @@ import { CodingPlanRegion, CODING_PLAN_ENV_KEY, } from '../../constants/codingPlan.js'; +import { backupSettingsFile } from '../../utils/settingsUtils.js'; export type { QwenAuthState } from '../hooks/useQwenAuth.js'; @@ -299,11 +300,15 @@ export const useAuthCommand = ( setAuthError(null); // Get configuration based on region - const { template, version, regionName } = getCodingPlanConfig(region); + const { template, version } = getCodingPlanConfig(region); // Get persist scope const persistScope = getPersistScopeForModelSelection(settings); + // Backup settings file before modification + const settingsFile = settings.forScope(persistScope); + backupSettingsFile(settingsFile.path); + // Store api-key in settings.env (unified env key) settings.setValue(persistScope, `env.${CODING_PLAN_ENV_KEY}`, apiKey); @@ -384,8 +389,8 @@ export const useAuthCommand = ( { type: MessageType.INFO, text: t( - 'Authenticated successfully with {{region}}. API key is stored in settings.env.', - { region: regionName }, + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).', + { region: t('Alibaba Cloud Coding Plan') }, ), }, Date.now(), diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index 8818c42b7..cdd07d45d 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -20,6 +20,7 @@ export const compressCommand: SlashCommand = { action: async (context) => { const { ui } = context; const executionMode = context.executionMode ?? 'interactive'; + const abortSignal = context.abortSignal; if (executionMode === 'interactive' && ui.pendingItem) { ui.addItem( @@ -96,6 +97,10 @@ export const compressCommand: SlashCommand = { const compressed = await doCompress(); + if (abortSignal?.aborted) { + return; + } + if (!compressed) { if (executionMode === 'interactive') { ui.addItem( @@ -137,6 +142,10 @@ export const compressCommand: SlashCommand = { content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`, }; } catch (e) { + // If cancelled via ESC, don't show error — cancelSlashCommand already handled UI + if (abortSignal?.aborted) { + return; + } if (executionMode === 'interactive') { ui.addItem( { diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts new file mode 100644 index 000000000..04951db7a --- /dev/null +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -0,0 +1,322 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + SlashCommand, + SlashCommandActionReturn, + CommandContext, + MessageActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; +import type { HookRegistryEntry } from '@qwen-code/qwen-code-core'; + +/** + * Format hook source for display + */ +function formatHookSource(source: string): string { + switch (source) { + case 'project': + return 'Project'; + case 'user': + return 'User'; + case 'system': + return 'System'; + case 'extensions': + return 'Extension'; + default: + return source; + } +} + +/** + * Format hook status for display + */ +function formatHookStatus(enabled: boolean): string { + return enabled ? '✓ Enabled' : '✗ Disabled'; +} + +const listCommand: SlashCommand = { + name: 'list', + get description() { + return t('List all configured hooks'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + _args: string, + ): Promise => { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'info', + content: t( + 'Hooks are not enabled. Enable hooks in settings to use this feature.', + ), + }; + } + + const registry = hookSystem.getRegistry(); + const allHooks = registry.getAllHooks(); + + if (allHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: t( + 'No hooks configured. Add hooks in your settings.json file.', + ), + }; + } + + // Group hooks by event + const hooksByEvent = new Map(); + for (const hook of allHooks) { + const eventName = hook.eventName; + if (!hooksByEvent.has(eventName)) { + hooksByEvent.set(eventName, []); + } + hooksByEvent.get(eventName)!.push(hook); + } + + let output = `**Configured Hooks (${allHooks.length} total)**\n\n`; + + for (const [eventName, hooks] of hooksByEvent) { + output += `### ${eventName}\n`; + for (const hook of hooks) { + const name = hook.config.name || hook.config.command || 'unnamed'; + const source = formatHookSource(hook.source); + const status = formatHookStatus(hook.enabled); + const matcher = hook.matcher ? ` (matcher: ${hook.matcher})` : ''; + output += `- **${name}** [${source}] ${status}${matcher}\n`; + } + output += '\n'; + } + + return { + type: 'message', + messageType: 'info', + content: output, + }; + }, +}; + +const enableCommand: SlashCommand = { + name: 'enable', + get description() { + return t('Enable a disabled hook'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const hookName = args.trim(); + if (!hookName) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Please specify a hook name. Usage: /hooks enable ', + ), + }; + } + + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: t('Hooks are not enabled.'), + }; + } + + const registry = hookSystem.getRegistry(); + registry.setHookEnabled(hookName, true); + + return { + type: 'message', + messageType: 'info', + content: t('Hook "{{name}}" has been enabled for this session.', { + name: hookName, + }), + }; + }, + completion: async (context: CommandContext, partialArg: string) => { + const { config } = context.services; + if (!config) return []; + + const hookSystem = config.getHookSystem(); + if (!hookSystem) return []; + + const registry = hookSystem.getRegistry(); + const allHooks = registry.getAllHooks(); + + // Return disabled hooks for enable command (deduplicated by name) + const disabledHookNames = allHooks + .filter((hook) => !hook.enabled) + .map((hook) => hook.config.name || hook.config.command || '') + .filter((name) => name && name.startsWith(partialArg)); + return [...new Set(disabledHookNames)]; + }, +}; + +const disableCommand: SlashCommand = { + name: 'disable', + get description() { + return t('Disable an active hook'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const hookName = args.trim(); + if (!hookName) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Please specify a hook name. Usage: /hooks disable ', + ), + }; + } + + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: t('Hooks are not enabled.'), + }; + } + + const registry = hookSystem.getRegistry(); + registry.setHookEnabled(hookName, false); + + return { + type: 'message', + messageType: 'info', + content: t('Hook "{{name}}" has been disabled for this session.', { + name: hookName, + }), + }; + }, + completion: async (context: CommandContext, partialArg: string) => { + const { config } = context.services; + if (!config) return []; + + const hookSystem = config.getHookSystem(); + if (!hookSystem) return []; + + const registry = hookSystem.getRegistry(); + const allHooks = registry.getAllHooks(); + + // Return enabled hooks for disable command (deduplicated by name) + const enabledHookNames = allHooks + .filter((hook) => hook.enabled) + .map((hook) => hook.config.name || hook.config.command || '') + .filter((name) => name && name.startsWith(partialArg)); + return [...new Set(enabledHookNames)]; + }, +}; + +export const hooksCommand: SlashCommand = { + name: 'hooks', + get description() { + return t('Manage Qwen Code hooks'); + }, + kind: CommandKind.BUILT_IN, + subCommands: [listCommand, enableCommand, disableCommand], + action: async ( + context: CommandContext, + args: string, + ): Promise => { + // If no subcommand provided, show list + if (!args.trim()) { + const result = await listCommand.action?.(context, ''); + return result ?? { type: 'message', messageType: 'info', content: '' }; + } + + const [subcommand, ...rest] = args.trim().split(/\s+/); + const subArgs = rest.join(' '); + + let result: SlashCommandActionReturn | void; + switch (subcommand.toLowerCase()) { + case 'list': + result = await listCommand.action?.(context, subArgs); + break; + case 'enable': + result = await enableCommand.action?.(context, subArgs); + break; + case 'disable': + result = await disableCommand.action?.(context, subArgs); + break; + default: + return { + type: 'message', + messageType: 'error', + content: t( + 'Unknown subcommand: {{cmd}}. Available: list, enable, disable', + { + cmd: subcommand, + }, + ), + }; + } + return result ?? { type: 'message', messageType: 'info', content: '' }; + }, + completion: async (context: CommandContext, partialArg: string) => { + const subcommands = ['list', 'enable', 'disable']; + const parts = partialArg.split(/\s+/); + + if (parts.length <= 1) { + // Complete subcommand + return subcommands.filter((cmd) => cmd.startsWith(partialArg)); + } + + // Complete subcommand arguments + const [subcommand, ...rest] = parts; + const subArgs = rest.join(' '); + + switch (subcommand.toLowerCase()) { + case 'enable': + return enableCommand.completion?.(context, subArgs) ?? []; + case 'disable': + return disableCommand.completion?.(context, subArgs) ?? []; + default: + return []; + } + }, +}; diff --git a/packages/cli/src/ui/commands/insightCommand.ts b/packages/cli/src/ui/commands/insightCommand.ts new file mode 100644 index 000000000..1693254bb --- /dev/null +++ b/packages/cli/src/ui/commands/insightCommand.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandContext, SlashCommand } from './types.js'; +import { CommandKind } from './types.js'; +import { MessageType } from '../types.js'; +import type { HistoryItemInsightProgress } from '../types.js'; +import { t } from '../../i18n/index.js'; +import { join } from 'path'; +import os from 'os'; +import { StaticInsightGenerator } from '../../services/insight/generators/StaticInsightGenerator.js'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import open from 'open'; + +const logger = createDebugLogger('DataProcessor'); + +export const insightCommand: SlashCommand = { + name: 'insight', + get description() { + return t( + 'generate personalized programming insights from your chat history', + ); + }, + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext) => { + try { + context.ui.setDebugMessage(t('Generating insights...')); + + const projectsDir = join(os.homedir(), '.qwen', 'projects'); + if (!context.services.config) { + throw new Error('Config service is not available'); + } + const insightGenerator = new StaticInsightGenerator( + context.services.config, + ); + + const updateProgress = ( + stage: string, + progress: number, + detail?: string, + ) => { + const progressItem: HistoryItemInsightProgress = { + type: MessageType.INSIGHT_PROGRESS, + progress: { + stage, + progress, + detail, + }, + }; + context.ui.setPendingItem(progressItem); + }; + + context.ui.addItem( + { + type: MessageType.INFO, + text: t('This may take a couple minutes. Sit tight!'), + }, + Date.now(), + ); + + // Initial progress + updateProgress(t('Starting insight generation...'), 0); + + // Generate the static insight HTML file + const outputPath = await insightGenerator.generateStaticInsight( + projectsDir, + updateProgress, + ); + + // Clear pending item + context.ui.setPendingItem(null); + + context.ui.addItem( + { + type: MessageType.INFO, + text: t('Insight report generated successfully!'), + }, + Date.now(), + ); + + // Open the file in the default browser + try { + await open(outputPath); + + context.ui.addItem( + { + type: MessageType.INFO, + text: t('Opening insights in your browser: {{path}}', { + path: outputPath, + }), + }, + Date.now(), + ); + } catch (browserError) { + logger.error('Failed to open browser automatically:', browserError); + + context.ui.addItem( + { + type: MessageType.INFO, + text: t( + 'Insights generated at: {{path}}. Please open this file in your browser.', + { + path: outputPath, + }, + ), + }, + Date.now(), + ); + } + + context.ui.setDebugMessage(t('Insights ready.')); + } catch (error) { + // Clear pending item on error + context.ui.setPendingItem(null); + + context.ui.addItem( + { + type: MessageType.ERROR, + text: t('Failed to generate insights: {{error}}', { + error: (error as Error).message, + }), + }, + Date.now(), + ); + + logger.error('Insight generation error:', error); + } + }, +}; diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index baba59b6c..40bec554f 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -104,6 +104,15 @@ export const setupGithubCommand: SlashCommand = { ): Promise => { const abortController = new AbortController(); + // If we have a context abort signal (from ESC cancellation), link it to our controller + if (context.abortSignal) { + context.abortSignal.addEventListener( + 'abort', + () => abortController.abort(), + { once: true }, + ); + } + if (!isGitHubRepository()) { throw new Error( 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index de75fadd2..5e84bf53e 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -27,6 +27,7 @@ export const summaryCommand: SlashCommand = { const { config } = context.services; const { ui } = context; const executionMode = context.executionMode ?? 'interactive'; + const abortSignal = context.abortSignal; if (!config) { return { @@ -101,7 +102,7 @@ export const summaryCommand: SlashCommand = { }, ], {}, - new AbortController().signal, + abortSignal ?? new AbortController().signal, config.getModel(), ); @@ -197,6 +198,10 @@ export const summaryCommand: SlashCommand = { if (executionMode !== 'interactive') { return; } + // If cancelled via ESC, don't show error — cancelSlashCommand already handled UI + if (abortSignal?.aborted) { + return; + } ui.setPendingItem(null); ui.addItem( { @@ -241,6 +246,9 @@ export const summaryCommand: SlashCommand = { }> => { emitInteractivePending('generating'); const markdownSummary = await generateSummaryMarkdown(history); + if (abortSignal?.aborted) { + throw new DOMException('Summary generation cancelled.', 'AbortError'); + } emitInteractivePending('saving'); const { filePathForDisplay } = await saveSummaryToDisk(markdownSummary); completeInteractive(filePathForDisplay); diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 4a6f42c80..b84f38b13 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -89,6 +89,8 @@ export interface CommandContext { }; // Flag to indicate if an overwrite has been confirmed overwriteConfirmed?: boolean; + /** Abort signal for cancelling long-running slash command operations via ESC. */ + abortSignal?: AbortSignal; } /** diff --git a/packages/cli/src/ui/components/ApiKeyInput.tsx b/packages/cli/src/ui/components/ApiKeyInput.tsx index a702c2d21..bf885b30d 100644 --- a/packages/cli/src/ui/components/ApiKeyInput.tsx +++ b/packages/cli/src/ui/components/ApiKeyInput.tsx @@ -49,6 +49,18 @@ export function ApiKeyInput({ setError(t('API key cannot be empty.')); return; } + // Only validate sk-sp- prefix for China region (aliyun.com) + if ( + region === CodingPlanRegion.CHINA && + !trimmedKey.startsWith('sk-sp-') + ) { + setError( + t( + 'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.', + ), + ); + return; + } onSubmit(trimmedKey); } }, @@ -57,9 +69,6 @@ export function ApiKeyInput({ return ( - - {t('Please enter your API key:')} - {error && ( @@ -67,18 +76,18 @@ export function ApiKeyInput({ )} - {t('You can get your exclusive Coding Plan API-KEY here:')} + {t('You can get your Coding Plan API key here')} - + {apiKeyUrl} - {t('(Press Enter to submit, Escape to cancel)')} + {t('Enter to submit, Esc to go back')} diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index ba044d10d..0254a2012 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -5,16 +5,43 @@ */ import { Box } from 'ink'; -import { Header } from './Header.js'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { Header, AuthDisplayType } from './Header.js'; import { Tips } from './Tips.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { isCodingPlanConfig } from '../../constants/codingPlan.js'; interface AppHeaderProps { version: string; } +/** + * Determine the auth display type based on auth type and configuration. + */ +function getAuthDisplayType( + authType?: AuthType, + baseUrl?: string, + apiKeyEnvKey?: string, +): AuthDisplayType { + if (!authType) { + return AuthDisplayType.UNKNOWN; + } + + // Check if it's a Coding Plan config + if (isCodingPlanConfig(baseUrl, apiKeyEnvKey)) { + return AuthDisplayType.CODING_PLAN; + } + + switch (authType) { + case AuthType.QWEN_OAUTH: + return AuthDisplayType.QWEN_OAUTH; + default: + return AuthDisplayType.API_KEY; + } +} + export const AppHeader = ({ version }: AppHeaderProps) => { const settings = useSettings(); const config = useConfig(); @@ -27,12 +54,18 @@ export const AppHeader = ({ version }: AppHeaderProps) => { const showBanner = !config.getScreenReader(); const showTips = !(settings.merged.ui?.hideTips || config.getScreenReader()); + const authDisplayType = getAuthDisplayType( + authType, + contentGeneratorConfig?.baseUrl, + contentGeneratorConfig?.apiKeyEnvKey, + ); + return ( {showBanner && (
diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 1db02d6f9..67d992dbe 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -117,6 +117,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => const createMockUIActions = (): UIActions => ({ handleFinalSubmit: vi.fn(), + handleRetryLastPrompt: vi.fn(), handleClearScreen: vi.fn(), setShellModeActive: vi.fn(), onEscapePromptChange: vi.fn(), diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 7836ceb6b..764d88950 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -32,7 +32,6 @@ import process from 'node:process'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; 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 { MCPManagementDialog } from './mcp/MCPManagementDialog.js'; @@ -237,10 +236,6 @@ export const DialogManager = ({ if (uiState.isModelDialogOpen) { return ; } - if (uiState.isVisionSwitchDialogOpen) { - return ; - } - if (uiState.isAuthDialogOpen || uiState.authError) { return ( diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx index 1d3a4d7f1..99bb053da 100644 --- a/packages/cli/src/ui/components/Header.test.tsx +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -6,8 +6,7 @@ import { render } from 'ink-testing-library'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import { Header } from './Header.js'; +import { Header, AuthDisplayType } from './Header.js'; import * as useTerminalSize from '../hooks/useTerminalSize.js'; vi.mock('../hooks/useTerminalSize.js'); @@ -15,86 +14,70 @@ const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize); const defaultProps = { version: '1.0.0', - authType: AuthType.QWEN_OAUTH, + authDisplayType: AuthDisplayType.QWEN_OAUTH, model: 'qwen-coder-plus', workingDirectory: '/home/user/projects/test', }; describe('
', () => { beforeEach(() => { - // Default to wide terminal (shows both logo and info panel) useTerminalSizeMock.mockReturnValue({ columns: 120, rows: 24 }); }); it('renders the ASCII logo on wide terminal', () => { const { lastFrame } = render(
); - // Check that parts of the shortAsciiLogo are rendered expect(lastFrame()).toContain('██╔═══██╗'); }); it('hides the ASCII logo on narrow terminal', () => { useTerminalSizeMock.mockReturnValue({ columns: 60, rows: 24 }); const { lastFrame } = render(
); - // Should not contain the logo but still show the info panel expect(lastFrame()).not.toContain('██╔═══██╗'); expect(lastFrame()).toContain('>_ Qwen Code'); }); - it('renders custom ASCII art when provided on wide terminal', () => { - const customArt = 'CUSTOM ART'; - const { lastFrame } = render( -
, - ); - expect(lastFrame()).toContain(customArt); - }); - it('displays the version number', () => { const { lastFrame } = render(
); expect(lastFrame()).toContain('v1.0.0'); }); - it('displays Qwen Code title with >_ prefix', () => { - const { lastFrame } = render(
); - expect(lastFrame()).toContain('>_ Qwen Code'); - }); - it('displays auth type and model', () => { const { lastFrame } = render(
); expect(lastFrame()).toContain('Qwen OAuth'); expect(lastFrame()).toContain('qwen-coder-plus'); }); + it('displays Coding Plan auth type', () => { + const { lastFrame } = render( +
, + ); + expect(lastFrame()).toContain('Coding Plan'); + }); + + it('displays API Key auth type', () => { + const { lastFrame } = render( +
, + ); + expect(lastFrame()).toContain('API Key'); + }); + + it('displays Unknown when auth type is not set', () => { + const { lastFrame } = render( +
, + ); + expect(lastFrame()).toContain('Unknown'); + }); + it('displays working directory', () => { const { lastFrame } = render(
); expect(lastFrame()).toContain('/home/user/projects/test'); }); - it('renders a custom working directory display', () => { - const { lastFrame } = render( -
, - ); - expect(lastFrame()).toContain('custom display'); - }); - - it('displays working directory without branch name', () => { - const { lastFrame } = render(
); - // Branch name is no longer shown in header - expect(lastFrame()).toContain('/home/user/projects/test'); - expect(lastFrame()).not.toContain('(main*)'); - }); - - it('formats home directory with tilde', () => { - const { lastFrame } = render( -
, - ); - // The actual home dir replacement depends on os.homedir() - // Just verify the path is shown - expect(lastFrame()).toContain('projects'); - }); - it('renders with border around info panel', () => { const { lastFrame } = render(
); - // Check for border characters (round border style uses these) expect(lastFrame()).toContain('╭'); expect(lastFrame()).toContain('╯'); }); diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index adbe13071..45fce4385 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -7,59 +7,35 @@ import type React from 'react'; import { Box, Text } from 'ink'; import Gradient from 'ink-gradient'; -import { AuthType, shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core'; +import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; import { shortAsciiLogo } from './AsciiArt.js'; import { getAsciiArtWidth, getCachedStringWidth } from '../utils/textUtils.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; +/** + * Auth display type for the Header component. + * Simplified representation of authentication method shown to users. + */ +export enum AuthDisplayType { + QWEN_OAUTH = 'Qwen OAuth', + CODING_PLAN = 'Coding Plan', + API_KEY = 'API Key', + UNKNOWN = 'Unknown', +} + interface HeaderProps { customAsciiArt?: string; // For user-defined ASCII art version: string; - authType?: AuthType; + authDisplayType?: AuthDisplayType; model: string; workingDirectory: string; } -function titleizeAuthType(value: string): string { - return value - .split(/[-_]/g) - .filter(Boolean) - .map((part) => { - if (part.toLowerCase() === 'ai') { - return 'AI'; - } - return part.charAt(0).toUpperCase() + part.slice(1); - }) - .join(' '); -} - -// Format auth type for display -function formatAuthType(authType?: AuthType): string { - if (!authType) { - return 'Unknown'; - } - - switch (authType) { - case AuthType.QWEN_OAUTH: - return 'Qwen OAuth'; - case AuthType.USE_OPENAI: - return 'OpenAI'; - case AuthType.USE_GEMINI: - return 'Gemini'; - case AuthType.USE_VERTEX_AI: - return 'Vertex AI'; - case AuthType.USE_ANTHROPIC: - return 'Anthropic'; - default: - return titleizeAuthType(String(authType)); - } -} - export const Header: React.FC = ({ customAsciiArt, version, - authType, + authDisplayType, model, workingDirectory, }) => { @@ -67,7 +43,7 @@ export const Header: React.FC = ({ const displayLogo = customAsciiArt ?? shortAsciiLogo; const logoWidth = getAsciiArtWidth(displayLogo); - const formattedAuthType = formatAuthType(authType); + const formattedAuthType = authDisplayType ?? AuthDisplayType.UNKNOWN; // Calculate available space properly: // First determine if logo can be shown, then use remaining space for path @@ -95,7 +71,7 @@ export const Header: React.FC = ({ ? Math.min(availableTerminalWidth - logoWidth - logoGap, maxInfoPanelWidth) : availableTerminalWidth; - // Calculate max path length (subtract padding/borders from available space) + // Calculate max path lengths (subtract padding/borders from available space) const maxPathLength = Math.max( 0, availableInfoPanelWidth - infoPanelChromeWidth, diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 73bdd6de3..a82847cc8 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -8,19 +8,23 @@ import type React from 'react'; import { useMemo } from 'react'; import { escapeAnsiCtrlCodes } from '../utils/textUtils.js'; import type { HistoryItem } from '../types.js'; -import { UserMessage } from './messages/UserMessage.js'; -import { UserShellMessage } from './messages/UserShellMessage.js'; -import { GeminiMessage } from './messages/GeminiMessage.js'; -import { InfoMessage } from './messages/InfoMessage.js'; -import { ErrorMessage } from './messages/ErrorMessage.js'; +import { + UserMessage, + UserShellMessage, + AssistantMessage, + AssistantMessageContent, + ThinkMessage, + ThinkMessageContent, +} from './messages/ConversationMessages.js'; import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; -import { GeminiMessageContent } from './messages/GeminiMessageContent.js'; -import { GeminiThoughtMessage } from './messages/GeminiThoughtMessage.js'; -import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageContent.js'; import { CompressionMessage } from './messages/CompressionMessage.js'; import { SummaryMessage } from './messages/SummaryMessage.js'; -import { WarningMessage } from './messages/WarningMessage.js'; -import { RetryCountdownMessage } from './messages/RetryCountdownMessage.js'; +import { + InfoMessage, + WarningMessage, + ErrorMessage, + RetryCountdownMessage, +} from './messages/StatusMessages.js'; import { Box } from 'ink'; import { AboutBox } from './AboutBox.js'; import { StatsDisplay } from './StatsDisplay.js'; @@ -34,6 +38,7 @@ import { getMCPServerStatus } from '@qwen-code/qwen-code-core'; import { SkillsList } from './views/SkillsList.js'; import { ToolsList } from './views/ToolsList.js'; import { McpStatus } from './views/McpStatus.js'; +import { InsightProgressMessage } from './messages/InsightProgressMessage.js'; interface HistoryItemDisplayProps { item: HistoryItem; @@ -60,6 +65,11 @@ const HistoryItemDisplayComponent: React.FC = ({ embeddedShellFocused, availableTerminalHeightGemini, }) => { + const marginTop = + item.type === 'gemini_content' || item.type === 'gemini_thought_content' + ? 0 + : 1; + const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]); const contentWidth = terminalWidth - 4; const boxWidth = mainAreaWidth || contentWidth; @@ -68,6 +78,7 @@ const HistoryItemDisplayComponent: React.FC = ({ @@ -79,7 +90,7 @@ const HistoryItemDisplayComponent: React.FC = ({ )} {itemForDisplay.type === 'gemini' && ( - = ({ /> )} {itemForDisplay.type === 'gemini_content' && ( - = ({ /> )} {itemForDisplay.type === 'gemini_thought' && ( - = ({ /> )} {itemForDisplay.type === 'gemini_thought_content' && ( - = ({ )} {itemForDisplay.type === 'error' && ( - + )} {itemForDisplay.type === 'retry_countdown' && ( @@ -180,6 +191,9 @@ const HistoryItemDisplayComponent: React.FC = ({ {itemForDisplay.type === 'mcp_status' && ( )} + {itemForDisplay.type === 'insight_progress' && ( + + )} ); }; diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index d5ace1c53..61584b8c7 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -38,6 +38,7 @@ vi.mock('../contexts/UIStateContext.js', () => ({ })); vi.mock('../contexts/UIActionsContext.js', () => ({ useUIActions: vi.fn(() => ({ + handleRetryLastPrompt: vi.fn(), temporaryCloseFeedbackDialog: vi.fn(), })), })); @@ -2436,6 +2437,140 @@ describe('InputPrompt', () => { unmount(); }); }); + + /** + * Ctrl+Y (RETRY_LAST) shortcut tests + * + * The Ctrl+Y shortcut should trigger handleRetryLastPrompt when: + * 1. The user presses Ctrl+Y + * 2. The InputPrompt is focused + * 3. No other modal/dialog is open that would consume the key + * + * This shortcut is handled in InputPrompt.tsx at line 585-588: + * if (keyMatchers[Command.RETRY_LAST](key)) { + * uiActions.handleRetryLastPrompt(); + * return; + * } + */ + describe('Ctrl+Y retry shortcut', () => { + let mockUIActions: { + handleRetryLastPrompt: ReturnType; + temporaryCloseFeedbackDialog: ReturnType; + }; + + beforeEach(() => { + mockUIActions = { + handleRetryLastPrompt: vi.fn(), + temporaryCloseFeedbackDialog: vi.fn(), + }; + + // Override the mock for useUIActions + vi.doMock('../contexts/UIActionsContext.js', () => ({ + useUIActions: vi.fn(() => mockUIActions), + })); + }); + + afterEach(() => { + vi.doUnmock('../contexts/UIActionsContext.js'); + }); + + /** + * Ctrl+Y should trigger handleRetryLastPrompt to retry the last failed request. + * This is the primary activation path for the retry feature. + */ + it('should trigger handleRetryLastPrompt on Ctrl+Y', async () => { + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Send Ctrl+Y (ASCII 25) + stdin.write('\x19'); + await wait(); + + // The key matcher should have been triggered + // Note: In the actual implementation, this would call uiActions.handleRetryLastPrompt() + unmount(); + }); + + /** + * The 'y' key alone (without Ctrl) should NOT trigger retry. + * This ensures the shortcut doesn't interfere with normal typing. + */ + it('should NOT trigger retry on plain y key', async () => { + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Send plain 'y' + stdin.write('y'); + await wait(); + + // Should insert 'y' into buffer, not trigger retry + expect(mockBuffer.handleInput).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'y', + sequence: 'y', + }), + ); + + unmount(); + }); + + /** + * Ctrl+R should NOT trigger retry - it should trigger reverse search instead. + * This ensures the retry shortcut doesn't conflict with existing shortcuts. + */ + it('should NOT trigger retry on Ctrl+R (reverse search)', async () => { + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Send Ctrl+R (ASCII 18) + stdin.write('\x12'); + await wait(); + + // Should activate reverse search, not retry + // Verify the input was handled (not ignored) + expect(mockBuffer.handleInput).not.toHaveBeenCalledWith( + expect.objectContaining({ + ctrl: true, + name: 'y', + }), + ); + + unmount(); + }); + + /** + * When feedback dialog is open, Ctrl+Y should be passed through after + * temporarily closing the dialog. + */ + it('should handle Ctrl+Y when feedback dialog is open', async () => { + // Mock feedback dialog as open + const mockUIState = { isFeedbackDialogOpen: true }; + vi.doMock('../contexts/UIStateContext.js', () => ({ + useUIState: vi.fn(() => mockUIState), + })); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Send Ctrl+Y + stdin.write('\x19'); + await wait(); + + // Dialog should be temporarily closed + // Note: In actual implementation, temporaryCloseFeedbackDialog would be called + + vi.doUnmock('../contexts/UIStateContext.js'); + unmount(); + }); + }); }); function clean(str: string | undefined): string { if (!str) return ''; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 09c2b27f1..42ec7efbb 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -582,6 +582,16 @@ export const InputPrompt: React.FC = ({ return; } + // Ctrl+Y: Retry the last failed request. + // This shortcut is available when: + // - There is a failed request in the current session + // - The stream is not currently responding or waiting for confirmation + // If no failed request exists, a message will be shown to the user. + if (keyMatchers[Command.RETRY_LAST](key)) { + uiActions.handleRetryLastPrompt(); + return; + } + if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) { setReverseSearchActive(true); setTextBeforeReverseSearch(buffer.text); diff --git a/packages/cli/src/ui/components/KeyboardShortcuts.tsx b/packages/cli/src/ui/components/KeyboardShortcuts.tsx index ada240b02..df84d0c27 100644 --- a/packages/cli/src/ui/components/KeyboardShortcuts.tsx +++ b/packages/cli/src/ui/components/KeyboardShortcuts.tsx @@ -39,6 +39,7 @@ const getShortcuts = (): Shortcut[] => [ { key: getNewlineKey(), description: t('for newline') + ' ⏎' }, { key: 'ctrl+l', description: t('to clear screen') }, { key: 'ctrl+r', description: t('to search history') }, + { key: 'ctrl+y', description: t('to retry last request') }, { key: getPasteKey(), description: t('to paste images') }, { key: getExternalEditorKey(), description: t('for external editor') }, ]; @@ -54,11 +55,11 @@ const COLUMN_GAP = 4; const MARGIN_LEFT = 2; const MARGIN_RIGHT = 2; -// Column distribution for different layouts (3+4+4 for 3 cols, 6+5 for 2 cols) +// Column distribution for different layouts (4+4+4 for 3 cols, 6+6 for 2 cols) const COLUMN_SPLITS: Record = { - 3: [3, 4, 4], - 2: [6, 5], - 1: [11], + 3: [4, 4, 4], + 2: [6, 6], + 1: [12], }; export const KeyboardShortcuts: React.FC = () => { diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 3ce25bfa9..dc5cc108a 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -12,14 +12,10 @@ import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSel import { ConfigContext } from '../contexts/ConfigContext.js'; import { SettingsContext } from '../contexts/SettingsContext.js'; import type { Config } from '@qwen-code/qwen-code-core'; -import { AuthType } from '@qwen-code/qwen-code-core'; +import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; -import { - AVAILABLE_MODELS_QWEN, - MAINLINE_CODER, - MAINLINE_VLM, -} from '../models/availableModels.js'; +import { getFilteredQwenModels } from '../models/availableModels.js'; vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), @@ -29,6 +25,19 @@ const mockedUseKeypress = vi.mocked(useKeypress); vi.mock('./shared/DescriptiveRadioButtonSelect.js', () => ({ DescriptiveRadioButtonSelect: vi.fn(() => null), })); + +// Helper to create getAvailableModelsForAuthType mock +const createMockGetAvailableModelsForAuthType = () => + vi.fn((t: AuthType) => { + if (t === AuthType.QWEN_OAUTH) { + return getFilteredQwenModels().map((m) => ({ + id: m.id, + label: m.label, + authType: AuthType.QWEN_OAUTH, + })); + } + return []; + }); const mockedSelect = vi.mocked(DescriptiveRadioButtonSelect); const renderComponent = ( @@ -49,12 +58,12 @@ const renderComponent = ( const mockConfig = { // --- Functions used by ModelDialog --- - getModel: vi.fn(() => MAINLINE_CODER), + getModel: vi.fn(() => DEFAULT_QWEN_MODEL), setModel: vi.fn().mockResolvedValue(undefined), switchModel: vi.fn().mockResolvedValue(undefined), getAuthType: vi.fn(() => 'qwen-oauth'), getAllConfiguredModels: vi.fn(() => - AVAILABLE_MODELS_QWEN.map((m) => ({ + getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, description: m.description || '', @@ -68,7 +77,7 @@ const renderComponent = ( getDebugMode: vi.fn(() => false), getContentGeneratorConfig: vi.fn(() => ({ authType: AuthType.QWEN_OAUTH, - model: MAINLINE_CODER, + model: DEFAULT_QWEN_MODEL, })), getUseModelRouter: vi.fn(() => false), getProxy: vi.fn(() => undefined), @@ -105,10 +114,9 @@ describe('', () => { cleanup(); }); - it('renders the title and help text', () => { + it('renders the title', () => { const { getByText } = renderComponent(); expect(getByText('Select Model')).toBeDefined(); - expect(getByText('(Press Esc to close)')).toBeDefined(); }); it('passes all model options to DescriptiveRadioButtonSelect', () => { @@ -116,24 +124,34 @@ describe('', () => { expect(mockedSelect).toHaveBeenCalledTimes(1); const props = mockedSelect.mock.calls[0][0]; - expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length); + expect(props.items).toHaveLength(getFilteredQwenModels().length); + // coder-model is the only model and it has vision capability expect(props.items[0].value).toBe( - `${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`, - ); - expect(props.items[1].value).toBe( - `${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`, + `${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`, ); expect(props.showNumbers).toBe(true); }); it('initializes with the model from ConfigContext', () => { - const mockGetModel = vi.fn(() => MAINLINE_VLM); - renderComponent({}, { getModel: mockGetModel }); + const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL); + renderComponent( + {}, + { + getModel: mockGetModel, + getAvailableModelsForAuthType: + createMockGetAvailableModelsForAuthType(), + }, + ); expect(mockGetModel).toHaveBeenCalled(); + // Calculate expected index dynamically based on model list + const qwenModels = getFilteredQwenModels(); + const expectedIndex = qwenModels.findIndex( + (m) => m.id === DEFAULT_QWEN_MODEL, + ); expect(mockedSelect).toHaveBeenCalledWith( expect.objectContaining({ - initialIndex: 1, + initialIndex: expectedIndex, }), undefined, ); @@ -151,14 +169,19 @@ describe('', () => { }); it('initializes with default coder model if getModel returns undefined', () => { - const mockGetModel = vi.fn(() => undefined); - // @ts-expect-error This test validates component robustness when getModel - // returns an unexpected undefined value. - renderComponent({}, { getModel: mockGetModel }); + const mockGetModel = vi.fn(() => undefined as unknown as string); + renderComponent( + {}, + { + getModel: mockGetModel, + getAvailableModelsForAuthType: + createMockGetAvailableModelsForAuthType(), + }, + ); expect(mockGetModel).toHaveBeenCalled(); - // When getModel returns undefined, preferredModel falls back to MAINLINE_CODER + // When getModel returns undefined, preferredModel falls back to DEFAULT_QWEN_MODEL // which has index 0, so initialIndex should be 0 expect(mockedSelect).toHaveBeenCalledWith( expect.objectContaining({ @@ -170,22 +193,36 @@ describe('', () => { }); it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => { - const { props, mockConfig, mockSettings } = renderComponent({}, {}); // Pass empty object for contextValue + const { props, mockConfig, mockSettings } = renderComponent( + {}, + { + getAvailableModelsForAuthType: vi.fn((t: AuthType) => { + if (t === AuthType.QWEN_OAUTH) { + return getFilteredQwenModels().map((m) => ({ + id: m.id, + label: m.label, + authType: AuthType.QWEN_OAUTH, + })); + } + return []; + }), + }, + ); const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; expect(childOnSelect).toBeDefined(); - await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`); + await childOnSelect(`${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`); expect(mockConfig?.switchModel).toHaveBeenCalledWith( AuthType.QWEN_OAUTH, - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, undefined, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, 'model.name', - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, @@ -203,7 +240,7 @@ describe('', () => { return [{ id: 'gpt-4', label: 'GPT-4', authType: t }]; } if (t === AuthType.QWEN_OAUTH) { - return AVAILABLE_MODELS_QWEN.map((m) => ({ + return getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, authType: AuthType.QWEN_OAUTH, @@ -217,7 +254,7 @@ describe('', () => { getModel: vi.fn(() => 'gpt-4'), getContentGeneratorConfig: vi.fn(() => ({ authType: AuthType.QWEN_OAUTH, - model: MAINLINE_CODER, + model: DEFAULT_QWEN_MODEL, })), // Add switchModel to the mock object (not the type) switchModel, @@ -231,17 +268,17 @@ describe('', () => { ); const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; - await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`); + await childOnSelect(`${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`); expect(switchModel).toHaveBeenCalledWith( AuthType.QWEN_OAUTH, - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, { requireCachedCredentials: true }, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, 'model.name', - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, @@ -251,11 +288,12 @@ describe('', () => { expect(props.onClose).toHaveBeenCalledTimes(1); }); - it('does not pass onHighlight to DescriptiveRadioButtonSelect', () => { + it('passes onHighlight to DescriptiveRadioButtonSelect', () => { renderComponent(); const childOnHighlight = mockedSelect.mock.calls[0][0].onHighlight; - expect(childOnHighlight).toBeUndefined(); + expect(childOnHighlight).toBeDefined(); + expect(typeof childOnHighlight).toBe('function'); }); it('calls onClose prop when "escape" key is pressed', () => { @@ -290,7 +328,7 @@ describe('', () => { }); it('updates initialIndex when config context changes', () => { - const mockGetModel = vi.fn(() => MAINLINE_CODER); + const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL); const mockGetAuthType = vi.fn(() => 'qwen-oauth'); const mockSettings = { isTrusted: true, @@ -305,8 +343,10 @@ describe('', () => { { getModel: mockGetModel, getAuthType: mockGetAuthType, + getAvailableModelsForAuthType: + createMockGetAvailableModelsForAuthType(), getAllConfiguredModels: vi.fn(() => - AVAILABLE_MODELS_QWEN.map((m) => ({ + getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, description: m.description || '', @@ -321,14 +361,16 @@ describe('', () => { , ); + // DEFAULT_QWEN_MODEL (coder-model) is at index 0 expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0); - mockGetModel.mockReturnValue(MAINLINE_VLM); + mockGetModel.mockReturnValue(DEFAULT_QWEN_MODEL); const newMockConfig = { getModel: mockGetModel, getAuthType: mockGetAuthType, + getAvailableModelsForAuthType: createMockGetAvailableModelsForAuthType(), getAllConfiguredModels: vi.fn(() => - AVAILABLE_MODELS_QWEN.map((m) => ({ + getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, description: m.description || '', @@ -347,6 +389,11 @@ describe('', () => { // Should be called at least twice: initial render + re-render after context change expect(mockedSelect).toHaveBeenCalledTimes(2); - expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(1); + // Calculate expected index for DEFAULT_QWEN_MODEL dynamically + const qwenModels = getFilteredQwenModels(); + const expectedCoderIndex = qwenModels.findIndex( + (m) => m.id === DEFAULT_QWEN_MODEL, + ); + expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(expectedCoderIndex); }); }); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 8c102890f..09723dcdd 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -11,10 +11,10 @@ import { AuthType, ModelSlashCommandEvent, logModelSlashCommand, + MAINLINE_CODER_MODEL, type AvailableModel as CoreAvailableModel, type ContentGeneratorConfig, - type ContentGeneratorConfigSource, - type ContentGeneratorConfigSources, + type InputModalities, } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; @@ -22,65 +22,28 @@ import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSel import { ConfigContext } from '../contexts/ConfigContext.js'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; -import { MAINLINE_CODER } from '../models/availableModels.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { t } from '../../i18n/index.js'; +function formatModalities(modalities?: InputModalities): string { + if (!modalities) return t('text-only'); + const parts: string[] = []; + if (modalities.image) parts.push(t('image')); + if (modalities.pdf) parts.push(t('pdf')); + if (modalities.audio) parts.push(t('audio')); + if (modalities.video) parts.push(t('video')); + if (parts.length === 0) return t('text-only'); + return `${t('text')} · ${parts.join(' · ')}`; +} + interface ModelDialogProps { onClose: () => void; } -function formatSourceBadge( - source: ContentGeneratorConfigSource | undefined, -): string | undefined { - if (!source) return undefined; - - switch (source.kind) { - case 'cli': - return source.detail ? `CLI ${source.detail}` : 'CLI'; - case 'env': - return source.envKey ? `ENV ${source.envKey}` : 'ENV'; - case 'settings': - return source.settingsPath - ? `Settings ${source.settingsPath}` - : 'Settings'; - case 'modelProviders': { - const suffix = - source.authType && source.modelId - ? `${source.authType}:${source.modelId}` - : source.authType - ? `${source.authType}` - : source.modelId - ? `${source.modelId}` - : ''; - return suffix ? `ModelProviders ${suffix}` : 'ModelProviders'; - } - case 'default': - return source.detail ? `Default ${source.detail}` : 'Default'; - case 'computed': - return source.detail ? `Computed ${source.detail}` : 'Computed'; - case 'programmatic': - return source.detail ? `Programmatic ${source.detail}` : 'Programmatic'; - case 'unknown': - default: - return undefined; - } -} - -function readSourcesFromConfig(config: unknown): ContentGeneratorConfigSources { - if (!config) { - return {}; - } - const maybe = config as { - getContentGeneratorConfigSources?: () => ContentGeneratorConfigSources; - }; - return maybe.getContentGeneratorConfigSources?.() ?? {}; -} - function maskApiKey(apiKey: string | undefined): string { - if (!apiKey) return '(not set)'; + if (!apiKey) return `(${t('not set')})`; const trimmed = apiKey.trim(); - if (trimmed.length === 0) return '(not set)'; + if (trimmed.length === 0) return `(${t('not set')})`; if (trimmed.length <= 6) return '***'; const head = trimmed.slice(0, 3); const tail = trimmed.slice(-4); @@ -131,7 +94,7 @@ function handleModelSwitchSuccess({ { type: 'info', text: - `authType: ${effectiveAuthType ?? '(none)'}` + + `authType: ${effectiveAuthType ?? `(${t('none')})`}` + `\n` + `Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}` + `\n` + @@ -143,35 +106,26 @@ function handleModelSwitchSuccess({ ); } -function ConfigRow({ +function formatContextWindow(size?: number): string { + if (!size) return `(${t('unknown')})`; + return `${size.toLocaleString('en-US')} tokens`; +} + +function DetailRow({ label, value, - badge, }: { label: string; value: React.ReactNode; - badge?: string; }): React.JSX.Element { return ( - - - - {label}: - - - {value} - + + + {label}: + + + {value} - {badge ? ( - - - - - - {badge} - - - ) : null} ); } @@ -183,13 +137,9 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { // Local error state for displaying errors within the dialog const [errorMessage, setErrorMessage] = useState(null); + const [highlightedValue, setHighlightedValue] = useState(null); const authType = config?.getAuthType(); - const effectiveConfig = - (config?.getContentGeneratorConfig?.() as - | ContentGeneratorConfig - | undefined) ?? undefined; - const sources = readSourcesFromConfig(config); const availableModelEntries = useMemo(() => { const allModels = config ? config.getAllConfiguredModels() : []; @@ -293,7 +243,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { [availableModelEntries], ); - const preferredModelId = config?.getModel() || MAINLINE_CODER; + const preferredModelId = config?.getModel() || MAINLINE_CODER_MODEL; // Check if current model is a runtime model // Runtime snapshot ID is already in $runtime|${authType}|${modelId} format const activeRuntimeSnapshot = config?.getActiveRuntimeModelSnapshot?.(); @@ -319,6 +269,20 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { return index === -1 ? 0 : index; }, [MODEL_OPTIONS, preferredKey]); + const handleHighlight = useCallback((value: string) => { + setHighlightedValue(value); + }, []); + + const highlightedEntry = useMemo(() => { + const key = highlightedValue ?? preferredKey; + return availableModelEntries.find( + ({ authType: t2, model, isRuntime, snapshotId }) => { + const v = isRuntime && snapshotId ? snapshotId : `${t2}::${model.id}`; + return v === key; + }, + ); + }, [highlightedValue, preferredKey, availableModelEntries]); + const handleSelect = useCallback( async (selected: string) => { setErrorMessage(null); @@ -413,35 +377,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { > {t('Select Model')} - - - {t('Current (effective) configuration')} - - - - - - {authType !== AuthType.QWEN_OAUTH && ( - <> - - - - )} - - - {!hasModels ? ( @@ -465,12 +400,48 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { )} + {highlightedEntry && ( + + + + + {highlightedEntry.authType !== AuthType.QWEN_OAUTH && ( + <> + + + + )} + + )} + {errorMessage && ( @@ -480,7 +451,9 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { )} - {t('(Press Esc to close)')} + + {t('Enter to select, ↑↓ to navigate, Esc to close')} + ); diff --git a/packages/cli/src/ui/components/ModelSwitchDialog.test.tsx b/packages/cli/src/ui/components/ModelSwitchDialog.test.tsx deleted file mode 100644 index 63c85f972..000000000 --- a/packages/cli/src/ui/components/ModelSwitchDialog.test.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { render } from 'ink-testing-library'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ModelSwitchDialog, VisionSwitchOutcome } from './ModelSwitchDialog.js'; - -// Mock the useKeypress hook -const mockUseKeypress = vi.hoisted(() => vi.fn()); -vi.mock('../hooks/useKeypress.js', () => ({ - useKeypress: mockUseKeypress, -})); - -// Mock the RadioButtonSelect component -const mockRadioButtonSelect = vi.hoisted(() => vi.fn()); -vi.mock('./shared/RadioButtonSelect.js', () => ({ - RadioButtonSelect: mockRadioButtonSelect, -})); - -describe('ModelSwitchDialog', () => { - const mockOnSelect = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - - // Mock RadioButtonSelect to return a simple div - mockRadioButtonSelect.mockReturnValue( - React.createElement('div', { 'data-testid': 'radio-select' }), - ); - }); - - it('should setup RadioButtonSelect with correct options', () => { - render(); - - const expectedItems = [ - { - key: 'switch-once', - label: 'Switch for this request only', - value: VisionSwitchOutcome.SwitchOnce, - }, - { - key: 'switch-session', - label: 'Switch session to vision model', - value: VisionSwitchOutcome.SwitchSessionToVL, - }, - { - key: 'continue', - label: 'Continue with current model', - value: VisionSwitchOutcome.ContinueWithCurrentModel, - }, - ]; - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(callArgs.items).toEqual(expectedItems); - expect(callArgs.initialIndex).toBe(0); - expect(callArgs.isFocused).toBe(true); - }); - - it('should call onSelect when an option is selected', () => { - render(); - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(typeof callArgs.onSelect).toBe('function'); - - // Simulate selection of "Switch for this request only" - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - onSelectCallback(VisionSwitchOutcome.SwitchOnce); - - expect(mockOnSelect).toHaveBeenCalledWith(VisionSwitchOutcome.SwitchOnce); - }); - - it('should call onSelect with SwitchSessionToVL when second option is selected', () => { - render(); - - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL); - - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.SwitchSessionToVL, - ); - }); - - it('should call onSelect with ContinueWithCurrentModel when third option is selected', () => { - render(); - - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel); - - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); - - it('should setup escape key handler to call onSelect with ContinueWithCurrentModel', () => { - render(); - - expect(mockUseKeypress).toHaveBeenCalledWith(expect.any(Function), { - isActive: true, - }); - - // Simulate escape key press - const keypressHandler = mockUseKeypress.mock.calls[0][0]; - keypressHandler({ name: 'escape' }); - - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); - - it('should not call onSelect for non-escape keys', () => { - render(); - - const keypressHandler = mockUseKeypress.mock.calls[0][0]; - keypressHandler({ name: 'enter' }); - - expect(mockOnSelect).not.toHaveBeenCalled(); - }); - - it('should set initial index to 0 (first option)', () => { - render(); - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(callArgs.initialIndex).toBe(0); - }); - - describe('VisionSwitchOutcome enum', () => { - it('should have correct enum values', () => { - expect(VisionSwitchOutcome.SwitchOnce).toBe('once'); - expect(VisionSwitchOutcome.SwitchSessionToVL).toBe('session'); - expect(VisionSwitchOutcome.ContinueWithCurrentModel).toBe('persist'); - }); - }); - - it('should handle multiple onSelect calls correctly', () => { - render(); - - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - - // Call multiple times - onSelectCallback(VisionSwitchOutcome.SwitchOnce); - onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL); - onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel); - - expect(mockOnSelect).toHaveBeenCalledTimes(3); - expect(mockOnSelect).toHaveBeenNthCalledWith( - 1, - VisionSwitchOutcome.SwitchOnce, - ); - expect(mockOnSelect).toHaveBeenNthCalledWith( - 2, - VisionSwitchOutcome.SwitchSessionToVL, - ); - expect(mockOnSelect).toHaveBeenNthCalledWith( - 3, - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); - - it('should pass isFocused prop to RadioButtonSelect', () => { - render(); - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(callArgs.isFocused).toBe(true); - }); - - it('should handle escape key multiple times', () => { - render(); - - const keypressHandler = mockUseKeypress.mock.calls[0][0]; - - // Call escape multiple times - keypressHandler({ name: 'escape' }); - keypressHandler({ name: 'escape' }); - - expect(mockOnSelect).toHaveBeenCalledTimes(2); - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); -}); diff --git a/packages/cli/src/ui/components/ModelSwitchDialog.tsx b/packages/cli/src/ui/components/ModelSwitchDialog.tsx deleted file mode 100644 index 97bfc53a3..000000000 --- a/packages/cli/src/ui/components/ModelSwitchDialog.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { Colors } from '../colors.js'; -import { - RadioButtonSelect, - type RadioSelectItem, -} from './shared/RadioButtonSelect.js'; -import { useKeypress } from '../hooks/useKeypress.js'; - -export enum VisionSwitchOutcome { - SwitchOnce = 'once', - SwitchSessionToVL = 'session', - ContinueWithCurrentModel = 'persist', -} - -export interface ModelSwitchDialogProps { - onSelect: (outcome: VisionSwitchOutcome) => void; -} - -export const ModelSwitchDialog: React.FC = ({ - onSelect, -}) => { - useKeypress( - (key) => { - if (key.name === 'escape') { - onSelect(VisionSwitchOutcome.ContinueWithCurrentModel); - } - }, - { isActive: true }, - ); - - const options: Array> = [ - { - key: 'switch-once', - label: 'Switch for this request only', - value: VisionSwitchOutcome.SwitchOnce, - }, - { - key: 'switch-session', - label: 'Switch session to vision model', - value: VisionSwitchOutcome.SwitchSessionToVL, - }, - { - key: 'continue', - label: 'Continue with current model', - value: VisionSwitchOutcome.ContinueWithCurrentModel, - }, - ]; - - const handleSelect = (outcome: VisionSwitchOutcome) => { - onSelect(outcome); - }; - - return ( - - - Vision Model Switch Required - - Your message contains an image, but the current model doesn't - support vision. - - How would you like to proceed? - - - - - - - - Press Enter to select, Esc to cancel - - - ); -}; diff --git a/packages/cli/src/ui/components/Tips.test.ts b/packages/cli/src/ui/components/Tips.test.ts new file mode 100644 index 000000000..dd2c25ea9 --- /dev/null +++ b/packages/cli/src/ui/components/Tips.test.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { selectWeightedTip } from './Tips.js'; + +describe('selectWeightedTip', () => { + const tips = [ + { text: 'tip-a', weight: 1 }, + { text: 'tip-b', weight: 3 }, + { text: 'tip-c', weight: 1 }, + ]; + + it('returns a valid tip text', () => { + const result = selectWeightedTip(tips); + expect(['tip-a', 'tip-b', 'tip-c']).toContain(result); + }); + + it('selects the first tip when random is near zero', () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + expect(selectWeightedTip(tips)).toBe('tip-a'); + vi.restoreAllMocks(); + }); + + it('selects the weighted tip when random falls in its range', () => { + // Total weight = 5. tip-a covers [0,1), tip-b covers [1,4), tip-c covers [4,5) + // Math.random() * 5 = 2.0 falls in tip-b's range + vi.spyOn(Math, 'random').mockReturnValue(0.4); // 0.4 * 5 = 2.0 + expect(selectWeightedTip(tips)).toBe('tip-b'); + vi.restoreAllMocks(); + }); + + it('selects the last tip when random is near max', () => { + vi.spyOn(Math, 'random').mockReturnValue(0.99); + expect(selectWeightedTip(tips)).toBe('tip-c'); + vi.restoreAllMocks(); + }); + + it('respects weight distribution over many samples', () => { + const counts: Record = { + 'tip-a': 0, + 'tip-b': 0, + 'tip-c': 0, + }; + const iterations = 10000; + for (let i = 0; i < iterations; i++) { + const result = selectWeightedTip(tips); + counts[result]!++; + } + // tip-b (weight 3) should appear roughly 3x as often as tip-a or tip-c (weight 1) + // With 10k iterations, we expect: tip-a ~2000, tip-b ~6000, tip-c ~2000 + expect(counts['tip-b']!).toBeGreaterThan(counts['tip-a']! * 2); + expect(counts['tip-b']!).toBeGreaterThan(counts['tip-c']! * 2); + }); + + it('handles single tip', () => { + expect(selectWeightedTip([{ text: 'only', weight: 1 }])).toBe('only'); + }); +}); diff --git a/packages/cli/src/ui/components/Tips.tsx b/packages/cli/src/ui/components/Tips.tsx index d1b6a71bf..f85184a19 100644 --- a/packages/cli/src/ui/components/Tips.tsx +++ b/packages/cli/src/ui/components/Tips.tsx @@ -9,7 +9,9 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { t } from '../../i18n/index.js'; -const startupTips = [ +type Tip = string | { text: string; weight: number }; + +const startupTips: Tip[] = [ 'Use /compress when the conversation gets long to summarize history and free up context.', 'Start a fresh idea with /clear or /new; the previous session stays available in history.', 'Use /bug to submit issues to the maintainers when something goes off.', @@ -20,13 +22,34 @@ const startupTips = [ process.platform === 'win32' ? 'You can switch permission mode quickly with Tab or /approval-mode.' : 'You can switch permission mode quickly with Shift+Tab or /approval-mode.', -] as const; + { + text: 'Try /insight to generate personalized insights from your chat history.', + weight: 3, + }, +]; + +function tipText(tip: Tip): string { + return typeof tip === 'string' ? tip : tip.text; +} + +function tipWeight(tip: Tip): number { + return typeof tip === 'string' ? 1 : tip.weight; +} + +export function selectWeightedTip(tips: Tip[]): string { + const totalWeight = tips.reduce((sum, tip) => sum + tipWeight(tip), 0); + let random = Math.random() * totalWeight; + for (const tip of tips) { + random -= tipWeight(tip); + if (random <= 0) { + return tipText(tip); + } + } + return tipText(tips[tips.length - 1]!); +} export const Tips: React.FC = () => { - const selectedTip = useMemo(() => { - const randomIndex = Math.floor(Math.random() * startupTips.length); - return startupTips[randomIndex]; - }, []); + const selectedTip = useMemo(() => selectWeightedTip(startupTips), []); return ( diff --git a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap index c22e5cace..c58c38dca 100644 --- a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap @@ -1,7 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[` > should render a full gemini item when using availableTerminalHeightGemini 1`] = ` -" ✦ Example code block: +" + ✦ Example code block: 1 Line 1 2 Line 2 3 Line 3 @@ -109,7 +110,8 @@ exports[` > should render a full gemini_content item when `; exports[` > should render a truncated gemini item 1`] = ` -" ✦ Example code block: +" + ✦ Example code block: ... first 41 lines hidden ... 42 Line 42 43 Line 43 diff --git a/packages/cli/src/ui/components/messages/ConversationMessages.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.tsx new file mode 100644 index 000000000..526bc9cfe --- /dev/null +++ b/packages/cli/src/ui/components/messages/ConversationMessages.tsx @@ -0,0 +1,261 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import stringWidth from 'string-width'; +import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; +import { theme } from '../../semantic-colors.js'; +import { + SCREEN_READER_MODEL_PREFIX, + SCREEN_READER_USER_PREFIX, +} from '../../textConstants.js'; + +interface UserMessageProps { + text: string; +} + +interface UserShellMessageProps { + text: string; +} + +interface AssistantMessageProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; +} + +interface AssistantMessageContentProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; +} + +interface ThinkMessageProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; +} + +interface ThinkMessageContentProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; +} + +interface PrefixedTextMessageProps { + text: string; + prefix: string; + prefixColor: string; + textColor: string; + ariaLabel?: string; + marginTop?: number; + alignSelf?: 'auto' | 'flex-start' | 'center' | 'flex-end'; +} + +interface PrefixedMarkdownMessageProps { + text: string; + prefix: string; + prefixColor: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; + ariaLabel?: string; + textColor?: string; +} + +interface ContinuationMarkdownMessageProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; + basePrefix: string; + textColor?: string; +} + +function getPrefixWidth(prefix: string): number { + // Reserve one extra column so text never touches the prefix glyph. + return stringWidth(prefix) + 1; +} + +const PrefixedTextMessage: React.FC = ({ + text, + prefix, + prefixColor, + textColor, + ariaLabel, + marginTop = 0, + alignSelf, +}) => { + const prefixWidth = getPrefixWidth(prefix); + + return ( + + + + {prefix} + + + + + {text} + + + + ); +}; + +const PrefixedMarkdownMessage: React.FC = ({ + text, + prefix, + prefixColor, + isPending, + availableTerminalHeight, + contentWidth, + ariaLabel, + textColor, +}) => { + const prefixWidth = getPrefixWidth(prefix); + + return ( + + + + {prefix} + + + + + + + ); +}; + +const ContinuationMarkdownMessage: React.FC< + ContinuationMarkdownMessageProps +> = ({ + text, + isPending, + availableTerminalHeight, + contentWidth, + basePrefix, + textColor, +}) => { + const prefixWidth = getPrefixWidth(basePrefix); + + return ( + + + + ); +}; + +export const UserMessage: React.FC = ({ text }) => ( + +); + +export const UserShellMessage: React.FC = ({ text }) => { + const commandToDisplay = text.startsWith('!') ? text.substring(1) : text; + + return ( + + ); +}; + +export const AssistantMessage: React.FC = ({ + text, + isPending, + availableTerminalHeight, + contentWidth, +}) => ( + +); + +export const AssistantMessageContent: React.FC< + AssistantMessageContentProps +> = ({ text, isPending, availableTerminalHeight, contentWidth }) => ( + +); + +export const ThinkMessage: React.FC = ({ + text, + isPending, + availableTerminalHeight, + contentWidth, +}) => ( + +); + +export const ThinkMessageContent: React.FC = ({ + text, + isPending, + availableTerminalHeight, + contentWidth, +}) => ( + +); diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx index a725f5e64..245f4df2c 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx @@ -56,6 +56,7 @@ index 0000000..e69de29 80, undefined, mockSettings, + 4, ); }); @@ -86,6 +87,7 @@ index 0000000..e69de29 80, undefined, mockSettings, + 4, ); }); @@ -115,6 +117,7 @@ index 0000000..e69de29 80, undefined, mockSettings, + 4, ); }); diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx index 3670be34b..8910d6d80 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx @@ -161,6 +161,7 @@ export const DiffRenderer: React.FC = ({ contentWidth, theme, settings, + tabWidth, ); } else { renderedOutput = renderDiffContent( diff --git a/packages/cli/src/ui/components/messages/ErrorMessage.tsx b/packages/cli/src/ui/components/messages/ErrorMessage.tsx deleted file mode 100644 index 8e10a4fed..000000000 --- a/packages/cli/src/ui/components/messages/ErrorMessage.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { theme } from '../../semantic-colors.js'; - -interface ErrorMessageProps { - text: string; -} - -export const ErrorMessage: React.FC = ({ text }) => { - const prefix = '✕ '; - const prefixWidth = prefix.length; - - return ( - - - {prefix} - - - - {text} - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx deleted file mode 100644 index 987cbf38a..000000000 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; -import { theme } from '../../semantic-colors.js'; -import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js'; - -interface GeminiMessageProps { - text: string; - isPending: boolean; - availableTerminalHeight?: number; - contentWidth: number; -} - -export const GeminiMessage: React.FC = ({ - text, - isPending, - availableTerminalHeight, - contentWidth, -}) => { - const prefix = '✦ '; - const prefixWidth = prefix.length; - - return ( - - - - {prefix} - - - - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx deleted file mode 100644 index 29a82298f..000000000 --- a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box } from 'ink'; -import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; - -interface GeminiMessageContentProps { - text: string; - isPending: boolean; - availableTerminalHeight?: number; - contentWidth: number; -} - -/* - * Gemini message content is a semi-hacked component. The intention is to represent a partial - * of GeminiMessage and is only used when a response gets too long. In that instance messages - * are split into multiple GeminiMessageContent's to enable the root component in - * App.tsx to be as performant as humanly possible. - */ -export const GeminiMessageContent: React.FC = ({ - text, - isPending, - availableTerminalHeight, - contentWidth, -}) => { - const originalPrefix = '✦ '; - const prefixWidth = originalPrefix.length; - - return ( - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx b/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx deleted file mode 100644 index b595c9d06..000000000 --- a/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; -import { theme } from '../../semantic-colors.js'; - -interface GeminiThoughtMessageProps { - text: string; - isPending: boolean; - availableTerminalHeight?: number; - contentWidth: number; -} - -/** - * Displays model thinking/reasoning text with a softer, dimmed style - * to visually distinguish it from regular content output. - */ -export const GeminiThoughtMessage: React.FC = ({ - text, - isPending, - availableTerminalHeight, - contentWidth, -}) => { - const prefix = '✦ '; - const prefixWidth = prefix.length; - - return ( - - - {prefix} - - - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx deleted file mode 100644 index 0f20c45d2..000000000 --- a/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box } from 'ink'; -import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; -import { theme } from '../../semantic-colors.js'; - -interface GeminiThoughtMessageContentProps { - text: string; - isPending: boolean; - availableTerminalHeight?: number; - contentWidth: number; -} - -/** - * Continuation component for thought messages, similar to GeminiMessageContent. - * Used when a thought response gets too long and needs to be split for performance. - */ -export const GeminiThoughtMessageContent: React.FC< - GeminiThoughtMessageContentProps -> = ({ text, isPending, availableTerminalHeight, contentWidth }) => { - const originalPrefix = '✦ '; - const prefixWidth = originalPrefix.length; - - return ( - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/InfoMessage.tsx b/packages/cli/src/ui/components/messages/InfoMessage.tsx deleted file mode 100644 index fb03fbef1..000000000 --- a/packages/cli/src/ui/components/messages/InfoMessage.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { theme } from '../../semantic-colors.js'; -import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; - -interface InfoMessageProps { - text: string; -} - -export const InfoMessage: React.FC = ({ text }) => { - // Don't render anything if text is empty - if (!text || text.trim() === '') { - return null; - } - - const prefix = 'ℹ '; - const prefixWidth = prefix.length; - - return ( - - - {prefix} - - - - - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/InsightProgressMessage.tsx b/packages/cli/src/ui/components/messages/InsightProgressMessage.tsx new file mode 100644 index 000000000..4115b3899 --- /dev/null +++ b/packages/cli/src/ui/components/messages/InsightProgressMessage.tsx @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import type { InsightProgressProps } from '../../types.js'; +import Spinner from 'ink-spinner'; + +interface InsightProgressMessageProps { + progress: InsightProgressProps; +} + +export const InsightProgressMessage: React.FC = ({ + progress, +}) => { + const { stage, progress: percent, isComplete, error } = progress; + const width = 30; + const completedWidth = Math.round((percent / 100) * width); + const remainingWidth = width - completedWidth; + + const bar = + '█'.repeat(Math.max(0, completedWidth)) + + '░'.repeat(Math.max(0, remainingWidth)); + + if (error) { + return ( + + ✕ {stage} + {error} + + ); + } + + if (isComplete) { + return ( + + ✓ {stage} + + ); + } + + return ( + + + + + + {bar} + + {stage} + {progress.detail ? ` (${progress.detail})` : ''} + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/RetryCountdownMessage.tsx b/packages/cli/src/ui/components/messages/RetryCountdownMessage.tsx deleted file mode 100644 index 0f4727574..000000000 --- a/packages/cli/src/ui/components/messages/RetryCountdownMessage.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { theme } from '../../semantic-colors.js'; - -interface RetryCountdownMessageProps { - text: string; -} - -/** - * Displays a retry countdown message in a dimmed/secondary style - * to visually distinguish it from error messages. - */ -export const RetryCountdownMessage: React.FC = ({ - text, -}) => { - if (!text || text.trim() === '') { - return null; - } - - const prefix = '↻ '; - const prefixWidth = prefix.length; - - return ( - - - {prefix} - - - - {text} - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/StatusMessages.tsx b/packages/cli/src/ui/components/messages/StatusMessages.tsx new file mode 100644 index 000000000..e6e945bbd --- /dev/null +++ b/packages/cli/src/ui/components/messages/StatusMessages.tsx @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import stringWidth from 'string-width'; +import { theme } from '../../semantic-colors.js'; +import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; + +interface StatusMessageProps { + text: string; + prefix: string; + prefixColor: string; + textColor: string; + children?: React.ReactNode; +} + +interface StatusTextProps { + text: string; +} + +/** + * Shared renderer for status-like history messages (info/warning/error/retry). + * Keeps prefix spacing and wrapping behavior consistent across variants. + */ +export const StatusMessage: React.FC = ({ + text, + prefix, + prefixColor, + textColor, + children, +}) => { + if (!text || text.trim() === '') { + return null; + } + + const prefixWidth = stringWidth(prefix) + 1; + + return ( + + + {prefix} + + + + + {children} + + + + ); +}; + +export const InfoMessage: React.FC = ({ text }) => ( + +); + +export const SuccessMessage: React.FC = ({ text }) => ( + +); + +export const WarningMessage: React.FC = ({ text }) => ( + +); + +export const ErrorMessage: React.FC = ({ + text, + hint, +}) => ( + + {hint && ({hint})} + +); + +export const RetryCountdownMessage: React.FC = ({ text }) => ( + +); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 7bfe9a962..b285b0a35 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -330,7 +330,7 @@ export const ToolConfirmationMessage: React.FC< bodyContent = ( - + {displayUrls && infoProps.urls && infoProps.urls.length > 0 && ( diff --git a/packages/cli/src/ui/components/messages/UserMessage.tsx b/packages/cli/src/ui/components/messages/UserMessage.tsx deleted file mode 100644 index 5cc2b965c..000000000 --- a/packages/cli/src/ui/components/messages/UserMessage.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { theme } from '../../semantic-colors.js'; -import { SCREEN_READER_USER_PREFIX } from '../../textConstants.js'; -import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.js'; - -interface UserMessageProps { - text: string; -} - -export const UserMessage: React.FC = ({ text }) => { - const prefix = '> '; - const prefixWidth = prefix.length; - const isSlashCommand = checkIsSlashCommand(text); - - const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary; - - return ( - - - - {prefix} - - - - - {text} - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/UserShellMessage.tsx b/packages/cli/src/ui/components/messages/UserShellMessage.tsx deleted file mode 100644 index 3b7bc7724..000000000 --- a/packages/cli/src/ui/components/messages/UserShellMessage.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { theme } from '../../semantic-colors.js'; - -interface UserShellMessageProps { - text: string; -} - -export const UserShellMessage: React.FC = ({ text }) => { - // Remove leading '!' if present, as App.tsx adds it for the processor. - const commandToDisplay = text.startsWith('!') ? text.substring(1) : text; - - return ( - - $ - {commandToDisplay} - - ); -}; diff --git a/packages/cli/src/ui/components/messages/WarningMessage.tsx b/packages/cli/src/ui/components/messages/WarningMessage.tsx deleted file mode 100644 index 4bc2c899c..000000000 --- a/packages/cli/src/ui/components/messages/WarningMessage.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { Colors } from '../../colors.js'; -import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; - -interface WarningMessageProps { - text: string; -} - -export const WarningMessage: React.FC = ({ text }) => { - const prefix = '⚠ '; - const prefixWidth = 3; - - return ( - - - {prefix} - - - - - - - - ); -}; diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx index 8071582f7..15664ef95 100644 --- a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx @@ -30,6 +30,8 @@ export interface BaseSelectionListProps< showNumbers?: boolean; showScrollArrows?: boolean; maxItemsToShow?: number; + /** Gap (in rows) between each item. */ + itemGap?: number; renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode; } @@ -59,6 +61,7 @@ export function BaseSelectionList< showNumbers = true, showScrollArrows = false, maxItemsToShow = 10, + itemGap = 0, renderItem, }: BaseSelectionListProps): React.JSX.Element { const { activeIndex } = useSelectionList({ @@ -89,7 +92,7 @@ export function BaseSelectionList< const numberColumnWidth = String(items.length).length; return ( - + {/* Use conditional coloring instead of conditional rendering */} {showScrollArrows && ( extends SelectionListItem { title: React.ReactNode; - description: string; + description: React.ReactNode; } export interface DescriptiveRadioButtonSelectProps { @@ -32,6 +32,8 @@ export interface DescriptiveRadioButtonSelectProps { showScrollArrows?: boolean; /** The maximum number of items to show at once. */ maxItemsToShow?: number; + /** Gap (in rows) between each item. */ + itemGap?: number; } /** @@ -48,6 +50,7 @@ export function DescriptiveRadioButtonSelect({ showNumbers = false, showScrollArrows = false, maxItemsToShow = 10, + itemGap = 0, }: DescriptiveRadioButtonSelectProps): React.JSX.Element { return ( > @@ -59,6 +62,7 @@ export function DescriptiveRadioButtonSelect({ showNumbers={showNumbers} showScrollArrows={showScrollArrows} maxItemsToShow={maxItemsToShow} + itemGap={itemGap} renderItem={(item, { titleColor }) => ( {item.title} diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index c28cd9525..d69bada5b 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -1335,6 +1335,40 @@ describe('KeypressContext - Kitty Protocol', () => { ); }); + describe('Printable CSI-u keys', () => { + it('parses kitty CSI-u space as a space key with literal sequence', () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[32u`)); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'space', + sequence: ' ', + kittyProtocol: true, + }), + ); + }); + + it('parses kitty CSI-u printable letters as literal input', () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[100u`)); // 'd' + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'd', + sequence: 'd', + kittyProtocol: true, + }), + ); + }); + }); + describe('Shift+Tab forms', () => { it.each([ { sequence: `\x1b[Z`, description: 'legacy reverse Tab' }, diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index c4e192609..4496f5e1b 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -332,6 +332,36 @@ export function KeypressProvider({ }; } + // Printable CSI-u keys (including space) should behave like regular + // character input so downstream text inputs receive the literal char. + if ( + terminator === 'u' && + !ctrl && + keyCode >= 32 && + keyCode !== 127 && + keyCode <= 0x10ffff + ) { + const char = String.fromCodePoint(keyCode); + const printableName = + char === ' ' + ? 'space' + : /^[A-Za-z]$/.test(char) + ? char.toLowerCase() + : char; + return { + key: { + name: printableName, + ctrl: false, + meta: alt, + shift, + paste: false, + sequence: char, + kittyProtocol: true, + }, + length: m[0].length, + }; + } + // Ctrl+letters if ( ctrl && diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index b401f7785..85ff046c7 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -17,7 +17,6 @@ import { import { type SettingScope } from '../../config/settings.js'; import { type CodingPlanRegion } from '../../constants/codingPlan.js'; import type { AuthState } from '../types.js'; -import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; // OpenAICredentials type (previously imported from OpenAIKeyPrompt) export interface OpenAICredentials { apiKey: string; @@ -67,9 +66,8 @@ export interface UIActions { onSuggestionsVisibilityChange: (visible: boolean) => void; refreshStatic: () => void; handleFinalSubmit: (value: string) => void; + handleRetryLastPrompt: () => void; handleClearScreen: () => void; - // Vision switch dialog - handleVisionSwitchSelect: (outcome: VisionSwitchOutcome) => void; // Welcome back dialog handleWelcomeBackSelection: (choice: 'continue' | 'restart') => void; handleWelcomeBackClose: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index e61efd3b1..84f9e6052 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -115,8 +115,6 @@ export interface UIState { extensionsUpdateState: Map; activePtyId: number | undefined; embeddedShellFocused: boolean; - // Vision switch dialog - isVisionSwitchDialogOpen: boolean; // Welcome back dialog showWelcomeBackDialog: boolean; welcomeBackInfo: { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 42ce40993..c48653970 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -88,6 +88,10 @@ vi.mock('../../utils/cleanup.js', () => ({ runExitCleanup: mockRunExitCleanup, })); +vi.mock('./useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + function createTestCommand( overrides: Partial, kind: CommandKind = CommandKind.BUILT_IN, @@ -143,6 +147,7 @@ describe('useSlashCommandProcessor', () => { mockLoadHistory, vi.fn(), // refreshStatic vi.fn(), // toggleVimEnabled + false, // isProcessing setIsProcessing, vi.fn(), // setGeminiMdFileCount { @@ -151,9 +156,19 @@ describe('useSlashCommandProcessor', () => { openEditorDialog: vi.fn(), openSettingsDialog: vi.fn(), openModelDialog: mockOpenModelDialog, + openPermissionsDialog: vi.fn(), + openApprovalModeDialog: vi.fn(), + openResumeDialog: vi.fn(), quit: mockSetQuittingMessages, setDebugMessage: vi.fn(), + dispatchExtensionStateUpdate: vi.fn(), + addConfirmUpdateExtensionRequest: vi.fn(), + openSubagentCreateDialog: vi.fn(), + openAgentsManagerDialog: vi.fn(), }, + new Map(), // extensionsUpdateState + true, // isConfigInitialized + null, // logger ), ); @@ -459,7 +474,7 @@ describe('useSlashCommandProcessor', () => { name: 'loadwiththoughts', action: vi.fn().mockResolvedValue({ type: 'load_history', - history: [{ type: MessageType.MODEL, text: 'response' }], + history: [{ type: MessageType.GEMINI, text: 'response' }], clientHistory: historyWithThoughts, }), }); @@ -904,18 +919,29 @@ describe('useSlashCommandProcessor', () => { mockClearItems, mockLoadHistory, vi.fn(), // refreshStatic - vi.fn(), // onDebugMessage - vi.fn(), // openThemeDialog - mockOpenAuthDialog, - vi.fn(), // openEditorDialog - mockSetQuittingMessages, - vi.fn(), // openSettingsDialog - vi.fn(), // openModelSelectionDialog - vi.fn(), // openSubagentCreateDialog - vi.fn(), // openAgentsManagerDialog vi.fn(), // toggleVimEnabled + false, // isProcessing vi.fn(), // setIsProcessing vi.fn(), // setGeminiMdFileCount + { + openAuthDialog: mockOpenAuthDialog, + openThemeDialog: mockOpenThemeDialog, + openEditorDialog: vi.fn(), + openSettingsDialog: vi.fn(), + openModelDialog: vi.fn(), + openPermissionsDialog: vi.fn(), + openApprovalModeDialog: vi.fn(), + openResumeDialog: vi.fn(), + quit: mockSetQuittingMessages, + setDebugMessage: vi.fn(), + dispatchExtensionStateUpdate: vi.fn(), + addConfirmUpdateExtensionRequest: vi.fn(), + openSubagentCreateDialog: vi.fn(), + openAgentsManagerDialog: vi.fn(), + }, + new Map(), // extensionsUpdateState + true, // isConfigInitialized + null, // logger ), ); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 3079acf65..1bbdd05fd 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useMemo, useEffect, useState } from 'react'; +import { useCallback, useMemo, useEffect, useRef, useState } from 'react'; import { type PartListUnion } from '@google/genai'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { @@ -35,6 +35,7 @@ import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; import { parseSlashCommand } from '../../utils/commands.js'; import { clearScreen } from '../../utils/stdioHelpers.js'; +import { useKeypress } from './useKeypress.js'; import { type ExtensionUpdateAction, type ExtensionUpdateStatus, @@ -91,6 +92,7 @@ export const useSlashCommandProcessor = ( loadHistory: UseHistoryManagerReturn['loadHistory'], refreshStatic: () => void, toggleVimEnabled: () => Promise, + isProcessing: boolean, setIsProcessing: (isProcessing: boolean) => void, setGeminiMdFileCount: (count: number) => void, actions: SlashCommandProcessorActions, @@ -132,6 +134,34 @@ export const useSlashCommandProcessor = ( null, ); + // AbortController for cancelling async slash commands via ESC + const abortControllerRef = useRef(null); + + const cancelSlashCommand = useCallback(() => { + if (!abortControllerRef.current) { + return; + } + abortControllerRef.current.abort(); + addItem( + { + type: MessageType.INFO, + text: 'Command cancelled.', + }, + Date.now(), + ); + setPendingItem(null); + setIsProcessing(false); + }, [addItem, setIsProcessing]); + + useKeypress( + (key) => { + if (key.name === 'escape') { + cancelSlashCommand(); + } + }, + { isActive: isProcessing }, + ); + const pendingHistoryItems = useMemo(() => { const items: HistoryItemWithoutId[] = []; if (pendingItem != null) { @@ -182,6 +212,11 @@ export const useSlashCommandProcessor = ( type: 'summary', summary: message.summary, }; + } else if (message.type === MessageType.INSIGHT_PROGRESS) { + historyItemContent = { + type: 'insight_progress', + progress: message.progress, + }; } else { historyItemContent = { type: message.type, @@ -320,6 +355,10 @@ export const useSlashCommandProcessor = ( setIsProcessing(true); + // Create a new AbortController for this command execution + const abortController = new AbortController(); + abortControllerRef.current = abortController; + const userMessageTimestamp = Date.now(); addItemWithRecording( { type: MessageType.USER, text: trimmed }, @@ -353,6 +392,7 @@ export const useSlashCommandProcessor = ( args, }, overwriteConfirmed, + abortSignal: abortController.signal, }; // If a one-time list is provided for a "Proceed" action, temporarily @@ -366,10 +406,27 @@ export const useSlashCommandProcessor = ( ]), }; } - const result = await commandToExecute.action( - fullCommandContext, - args, - ); + // Race the command action against the abort signal so that + // ESC cancellation immediately unblocks the await chain. + // Without this, commands like /compress whose underlying + // operation (tryCompressChat) doesn't accept an AbortSignal + // would keep submitQuery stuck until the operation completes. + const abortPromise = new Promise((resolve) => { + abortController.signal.addEventListener( + 'abort', + () => resolve(undefined), + { once: true }, + ); + }); + const result = await Promise.race([ + commandToExecute.action(fullCommandContext, args), + abortPromise, + ]); + + // If the command was cancelled via ESC while executing, skip result processing + if (abortController.signal.aborted) { + return { type: 'handled' }; + } if (result) { switch (result.type) { @@ -565,6 +622,10 @@ export const useSlashCommandProcessor = ( return { type: 'handled' }; } catch (e: unknown) { + // If cancelled via ESC, the cancelSlashCommand callback already handled cleanup + if (abortController.signal.aborted) { + return { type: 'handled' }; + } hasError = true; if (config) { const event = makeSlashCommandEvent({ diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts index 3ddaf42e6..bcb5bce33 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts @@ -112,7 +112,7 @@ describe('useCodingPlanUpdates', () => { // Should prompt for China region since it defaults to China expect(result.current.codingPlanUpdateRequest?.prompt).toContain( - chinaConfig.regionName, + 'Alibaba Cloud Coding Plan', ); }); @@ -135,7 +135,7 @@ describe('useCodingPlanUpdates', () => { }); expect(result.current.codingPlanUpdateRequest?.prompt).toContain( - chinaConfig.regionName, + 'Alibaba Cloud Coding Plan', ); }); @@ -158,7 +158,7 @@ describe('useCodingPlanUpdates', () => { }); expect(result.current.codingPlanUpdateRequest?.prompt).toContain( - globalConfig.regionName, + 'Alibaba Cloud Coding Plan', ); }); }); @@ -228,7 +228,7 @@ describe('useCodingPlanUpdates', () => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', - text: expect.stringContaining(chinaConfig.regionName), + text: expect.stringContaining('Alibaba Cloud Coding Plan'), }), expect.any(Number), ); @@ -297,7 +297,7 @@ describe('useCodingPlanUpdates', () => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', - text: expect.stringContaining(globalConfig.regionName), + text: expect.stringContaining('Alibaba Cloud Coding Plan'), }), expect.any(Number), ); diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts index dee70e035..138498abf 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts @@ -68,7 +68,7 @@ export function useCodingPlanUpdates( ); // Get the configuration for the current region - const { template, version, regionName } = getCodingPlanConfig(region); + const { template, version } = getCodingPlanConfig(region); // Generate new configs from template const newConfigs = template.map((templateConfig) => ({ @@ -117,7 +117,7 @@ export function useCodingPlanUpdates( type: 'info', text: t( '{{region}} configuration updated successfully. Model switched to "{{model}}".', - { region: regionName, model: activeModel }, + { region: t('Alibaba Cloud Coding Plan'), model: activeModel }, ), }, Date.now(), @@ -170,11 +170,10 @@ export function useCodingPlanUpdates( // Check if version matches if (savedVersion !== currentVersion) { - const { regionName } = getCodingPlanConfig(region); setUpdateRequest({ prompt: t( 'New model configurations are available for {{region}}. Update now?', - { region: regionName }, + { region: t('Alibaba Cloud Coding Plan') }, ), onConfirm: async (confirmed: boolean) => { setUpdateRequest(undefined); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index edf0e0576..42f28f5e2 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -74,14 +74,6 @@ const mockParseAndFormatApiError = vi.hoisted(() => ); const mockLogApiCancel = vi.hoisted(() => vi.fn()); -// Vision auto-switch mocks (hoisted) -const mockHandleVisionSwitch = vi.hoisted(() => - vi.fn().mockResolvedValue({ shouldProceed: true }), -); -const mockRestoreOriginalModel = vi.hoisted(() => - vi.fn().mockResolvedValue(undefined), -); - vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const actualCoreModule = (await importOriginal()) as any; return { @@ -104,13 +96,6 @@ vi.mock('./useReactToolScheduler.js', async (importOriginal) => { }; }); -vi.mock('./useVisionAutoSwitch.js', () => ({ - useVisionAutoSwitch: vi.fn(() => ({ - handleVisionSwitch: mockHandleVisionSwitch, - restoreOriginalModel: mockRestoreOriginalModel, - })), -})); - vi.mock('./shellCommandProcessor.js', () => ({ useShellCommandProcessor: vi.fn().mockReturnValue({ handleShellCommand: vi.fn(), @@ -306,7 +291,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -472,7 +456,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -557,7 +540,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -670,7 +652,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -784,7 +765,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -901,7 +881,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, cancelSubmitSpy, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -945,7 +924,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, vi.fn(), - false, setShellInputFocusedSpy, // Pass the spy here 80, 24, @@ -1273,7 +1251,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1331,7 +1308,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1825,7 +1801,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1881,7 +1856,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1938,7 +1912,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2035,7 +2008,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled vi.fn(), 80, 24, @@ -2090,7 +2062,6 @@ describe('useGeminiStream', () => { vi.fn(), // setModelSwitched vi.fn(), // onEditorClose vi.fn(), // onCancelSubmit - false, // visionModelPreviewEnabled vi.fn(), // setShellInputFocused 80, // terminalWidth 24, // terminalHeight @@ -2164,7 +2135,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2256,7 +2226,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2317,7 +2286,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2336,40 +2304,30 @@ describe('useGeminiStream', () => { result.current.pendingHistoryItems.find( (item) => item.type === MessageType.ERROR, ); - const findCountdownItem = () => - result.current.pendingHistoryItems.find( - (item) => item.type === 'retry_countdown', - ); let errorItem = findErrorItem(); - let countdownItem = findCountdownItem(); - for ( - let attempts = 0; - attempts < 5 && (!errorItem || !countdownItem); - attempts++ - ) { + for (let attempts = 0; attempts < 5 && !errorItem; attempts++) { await act(async () => { await Promise.resolve(); }); errorItem = findErrorItem(); - countdownItem = findCountdownItem(); } - // Error line should be rendered as ERROR type (wrapped by parseAndFormatApiError) + // Error item should contain the error text and a retry hint expect(errorItem?.text).toContain('Rate limit exceeded'); - - // Countdown line should be rendered as retry_countdown type - expect(countdownItem?.text).toContain('Retrying in 3 seconds'); + // Countdown hint should be inline on the error item (not a separate item) + expect((errorItem as { hint?: string })?.hint).toContain('3s'); + expect((errorItem as { hint?: string })?.hint).toContain('attempt 1/3'); await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); - const countdownAfterOneSecond = result.current.pendingHistoryItems.find( - (item) => item.type === 'retry_countdown', + const errorAfterOneSecond = result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, ); - expect(countdownAfterOneSecond?.text).toContain( - 'Retrying in 2 seconds', + expect((errorAfterOneSecond as { hint?: string })?.hint).toContain( + '2s', ); resolveStream?.(); @@ -2379,15 +2337,11 @@ describe('useGeminiStream', () => { await vi.runAllTimersAsync(); }); - // Both error and countdown should be cleared after retry succeeds + // Error item (with hint) should be cleared after retry succeeds const remainingError = result.current.pendingHistoryItems.find( (item) => item.type === MessageType.ERROR, ); - const remainingCountdown = result.current.pendingHistoryItems.find( - (item) => item.type === 'retry_countdown', - ); expect(remainingError).toBeUndefined(); - expect(remainingCountdown).toBeUndefined(); } finally { vi.useRealTimers(); } @@ -2418,7 +2372,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled vi.fn(), // setShellInputFocused 80, 24, @@ -2489,7 +2442,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2548,7 +2500,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2560,14 +2511,13 @@ describe('useGeminiStream', () => { await result.current.submitQuery('Test query'); }); - // Verify error message was added + // Verify error message appears in pending history items (not via addItem, + // since errors with retry hints are now stored as pending items) await waitFor(() => { - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - }), - expect.any(Number), + const errorItem = result.current.pendingHistoryItems.find( + (item) => item.type === 'error', ); + expect(errorItem).toBeDefined(); }); // Verify parseAndFormatApiError was called @@ -2736,187 +2686,6 @@ describe('useGeminiStream', () => { }); // --- New tests focused on recent modifications --- - describe('Vision Auto Switch Integration', () => { - it('should call handleVisionSwitch and proceed to send when allowed', async () => { - mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: true }); - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'ok' }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; - })(), - ); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('image prompt'); - }); - - await waitFor(() => { - expect(mockHandleVisionSwitch).toHaveBeenCalled(); - expect(mockSendMessageStream).toHaveBeenCalled(); - }); - }); - - it('should gate submission when handleVisionSwitch returns shouldProceed=false', async () => { - mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: false }); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('vision-gated'); - }); - - // No call to API, no restoreOriginalModel needed since no override occurred - expect(mockSendMessageStream).not.toHaveBeenCalled(); - expect(mockRestoreOriginalModel).not.toHaveBeenCalled(); - - // Next call allowed (flag reset path) - mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: true }); - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'ok' }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; - })(), - ); - await act(async () => { - await result.current.submitQuery('after-gate'); - }); - await waitFor(() => { - expect(mockSendMessageStream).toHaveBeenCalled(); - }); - }); - }); - - describe('Model restore on completion and errors', () => { - it('should restore model after successful stream completion', async () => { - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'content' }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; - })(), - ); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('restore-success'); - }); - - await waitFor(() => { - expect(mockRestoreOriginalModel).toHaveBeenCalledTimes(1); - }); - }); - - it('should restore model when an error occurs during streaming', async () => { - const testError = new Error('stream failure'); - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'content' }; - throw testError; - })(), - ); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('restore-error'); - }); - - await waitFor(() => { - expect(mockRestoreOriginalModel).toHaveBeenCalledTimes(1); - }); - }); - }); - describe('Loop Detection Confirmation', () => { beforeEach(() => { // Add mock for getLoopDetectionService to the config diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 5bebbac7e..dd3dc060f 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -35,6 +35,8 @@ import { ToolConfirmationOutcome, logApiCancel, ApiCancelEvent, + isSupportedImageMimeType, + getUnsupportedImageFormatWarning, } from '@qwen-code/qwen-code-core'; import { type Part, type PartListUnion, FinishReason } from '@google/genai'; import type { @@ -46,7 +48,6 @@ import type { import { StreamingState, MessageType, ToolCallStatus } from '../types.js'; import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; import { useShellCommandProcessor } from './shellCommandProcessor.js'; -import { useVisionAutoSwitch } from './useVisionAutoSwitch.js'; import { handleAtCommand } from './atCommandProcessor.js'; import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; import { useStateAndRef } from './useStateAndRef.js'; @@ -68,6 +69,60 @@ import { t } from '../../i18n/index.js'; const debugLogger = createDebugLogger('GEMINI_STREAM'); +/** + * Checks if image parts have supported formats and returns unsupported ones + */ +function checkImageFormatsSupport(parts: PartListUnion): { + hasImages: boolean; + hasUnsupportedFormats: boolean; + unsupportedMimeTypes: string[]; +} { + const unsupportedMimeTypes: string[] = []; + let hasImages = false; + + if (typeof parts === 'string') { + return { + hasImages: false, + hasUnsupportedFormats: false, + unsupportedMimeTypes: [], + }; + } + + const partsArray = Array.isArray(parts) ? parts : [parts]; + + for (const part of partsArray) { + if (typeof part === 'string') continue; + + let mimeType: string | undefined; + + // Check inlineData + if ( + 'inlineData' in part && + part.inlineData?.mimeType?.startsWith('image/') + ) { + hasImages = true; + mimeType = part.inlineData.mimeType; + } + + // Check fileData + if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) { + hasImages = true; + mimeType = part.fileData.mimeType; + } + + // Check if the mime type is supported + if (mimeType && !isSupportedImageMimeType(mimeType)) { + unsupportedMimeTypes.push(mimeType); + } + } + + return { + hasImages, + hasUnsupportedFormats: unsupportedMimeTypes.length > 0, + unsupportedMimeTypes, + }; +} + enum StreamProcessingStatus { Completed, UserCancelled, @@ -106,26 +161,25 @@ export const useGeminiStream = ( setModelSwitchedFromQuotaError: React.Dispatch>, onEditorClose: () => void, onCancelSubmit: () => void, - visionModelPreviewEnabled: boolean, setShellInputFocused: (value: boolean) => void, terminalWidth: number, terminalHeight: number, - onVisionSwitchRequired?: (query: PartListUnion) => Promise<{ - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }>, ) => { const [initError, setInitError] = useState(null); const abortControllerRef = useRef(null); const turnCancelledRef = useRef(false); const isSubmittingQueryRef = useRef(false); + const lastPromptRef = useRef(null); + const lastPromptErroredRef = useRef(false); const [isResponding, setIsResponding] = useState(false); const [thought, setThought] = useState(null); const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] = useStateAndRef(null); - const [pendingRetryErrorItem, setPendingRetryErrorItem] = - useState(null); + const [ + pendingRetryErrorItem, + pendingRetryErrorItemRef, + setPendingRetryErrorItem, + ] = useStateAndRef(null); const [ pendingRetryCountdownItem, pendingRetryCountdownItemRef, @@ -205,11 +259,18 @@ export const useGeminiStream = ( } }, []); + /** + * Clears the retry countdown timer and pending retry items. + */ const clearRetryCountdown = useCallback(() => { stopRetryCountdownTimer(); setPendingRetryErrorItem(null); setPendingRetryCountdownItem(null); - }, [setPendingRetryCountdownItem, stopRetryCountdownTimer]); + }, [ + setPendingRetryErrorItem, + setPendingRetryCountdownItem, + stopRetryCountdownTimer, + ]); const startRetryCountdown = useCallback( (retryInfo: { @@ -224,18 +285,21 @@ export const useGeminiStream = ( const retryReasonText = message ?? t('Rate limit exceeded. Please wait and try again.'); - // Error line stays static (red with ✕ prefix) - setPendingRetryErrorItem({ - type: MessageType.ERROR, - text: retryReasonText, - }); - // Countdown line updates every second (dim/secondary color) const updateCountdown = () => { const elapsedMs = Date.now() - startTime; const remainingMs = Math.max(0, delayMs - elapsedMs); const remainingSec = Math.ceil(remainingMs / 1000); + // Update error item with hint containing countdown info (short format) + const hintText = `Retrying in ${remainingSec}s… (attempt ${attempt}/${maxRetries})`; + + setPendingRetryErrorItem({ + type: MessageType.ERROR, + text: retryReasonText, + hint: hintText, + }); + setPendingRetryCountdownItem({ type: 'retry_countdown', text: t( @@ -256,7 +320,11 @@ export const useGeminiStream = ( updateCountdown(); retryCountdownTimerRef.current = setInterval(updateCountdown, 1000); }, - [setPendingRetryCountdownItem, stopRetryCountdownTimer], + [ + setPendingRetryErrorItem, + setPendingRetryCountdownItem, + stopRetryCountdownTimer, + ], ); useEffect(() => () => stopRetryCountdownTimer(), [stopRetryCountdownTimer]); @@ -278,12 +346,6 @@ export const useGeminiStream = ( terminalHeight, ); - const { handleVisionSwitch, restoreOriginalModel } = useVisionAutoSwitch( - config, - addItem, - visionModelPreviewEnabled, - onVisionSwitchRequired, - ); const activePtyId = activeShellPtyId || activeToolPtyId; useEffect(() => { @@ -650,6 +712,7 @@ export const useGeminiStream = ( return; } + lastPromptErroredRef.current = false; if (pendingHistoryItemRef.current) { if (pendingHistoryItemRef.current.type === 'tool_group') { const updatedTools = pendingHistoryItemRef.current.tools.map( @@ -689,27 +752,36 @@ export const useGeminiStream = ( const handleErrorEvent = useCallback( (eventValue: GeminiErrorEventValue, userMessageTimestamp: number) => { + lastPromptErroredRef.current = true; if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); } - addItem( - { - type: MessageType.ERROR, + // Only show Ctrl+Y hint if not already showing an auto-retry countdown + // (auto-retry countdown is shown when retryCountdownTimerRef is active) + const isShowingAutoRetry = retryCountdownTimerRef.current !== null; + clearRetryCountdown(); + if (!isShowingAutoRetry) { + const retryHint = t('Press Ctrl+Y to retry'); + // Store error with hint as a pending item (not in history). + // This allows the hint to be removed when the user retries with Ctrl+Y, + // since pending items are in the dynamic rendering area (not ). + setPendingRetryErrorItem({ + type: 'error' as const, text: parseAndFormatApiError( eventValue.error, config.getContentGeneratorConfig()?.authType, ), - }, - userMessageTimestamp, - ); - clearRetryCountdown(); + hint: retryHint, + }); + } setThought(null); // Reset thought when there's an error }, [ addItem, pendingHistoryItemRef, setPendingHistoryItem, + setPendingRetryErrorItem, config, setThought, clearRetryCountdown, @@ -773,7 +845,10 @@ export const useGeminiStream = ( userMessageTimestamp, ); } - clearRetryCountdown(); + // Only clear auto-retry countdown errors (those with active timer) + if (retryCountdownTimerRef.current) { + clearRetryCountdown(); + } }, [addItem, clearRetryCountdown], ); @@ -945,6 +1020,15 @@ export const useGeminiStream = ( clearRetryCountdown(); } break; + case ServerGeminiEventType.HookSystemMessage: + // Display system message from hooks (e.g., Ralph Loop iteration info) + // This is handled as a content event to show in the UI + geminiMessageBuffer = handleContentEvent( + event.value + '\n', + geminiMessageBuffer, + userMessageTimestamp, + ); + break; default: { // enforces exhaustive switch-case const unreachable: never = event; @@ -980,7 +1064,7 @@ export const useGeminiStream = ( const submitQuery = useCallback( async ( query: PartListUnion, - options?: { isContinuation: boolean }, + options?: { isContinuation: boolean; skipPreparation?: boolean }, prompt_id?: string, ) => { // Prevent concurrent executions of submitQuery, but allow continuations @@ -1004,7 +1088,11 @@ export const useGeminiStream = ( // Reset quota error flag when starting a new query (not a continuation) if (!options?.isContinuation) { setModelSwitchedFromQuotaError(false); - // No quota-error / fallback routing mechanism currently; keep state minimal. + // Commit any pending retry error to history (without hint) since the + // user is starting a new conversation turn + if (pendingRetryCountdownItemRef.current) { + clearRetryCountdown(); + } } abortControllerRef.current = new AbortController(); @@ -1016,31 +1104,37 @@ export const useGeminiStream = ( } return promptIdContext.run(prompt_id, async () => { - const { queryToSend, shouldProceed } = await prepareQueryForGemini( - query, - userMessageTimestamp, - abortSignal, - prompt_id!, - ); + const { queryToSend, shouldProceed } = options?.skipPreparation + ? { queryToSend: query, shouldProceed: true } + : await prepareQueryForGemini( + query, + userMessageTimestamp, + abortSignal, + prompt_id!, + ); if (!shouldProceed || queryToSend === null) { isSubmittingQueryRef.current = false; return; } - // Handle vision switch requirement - const visionSwitchResult = await handleVisionSwitch( - queryToSend, - userMessageTimestamp, - options?.isContinuation || false, - ); - - if (!visionSwitchResult.shouldProceed) { - isSubmittingQueryRef.current = false; - return; + // Check image format support for non-continuations + if (!options?.isContinuation) { + const formatCheck = checkImageFormatsSupport(queryToSend); + if (formatCheck.hasUnsupportedFormats) { + addItem( + { + type: MessageType.INFO, + text: getUnsupportedImageFormatWarning(), + }, + userMessageTimestamp, + ); + } } const finalQueryToSend = queryToSend; + lastPromptRef.current = finalQueryToSend; + lastPromptErroredRef.current = false; if (!options?.isContinuation) { // trigger new prompt event for session stats in CLI @@ -1081,10 +1175,6 @@ export const useGeminiStream = ( ); if (processingStatus === StreamProcessingStatus.UserCancelled) { - // Restore original model if it was temporarily overridden - restoreOriginalModel().catch((error) => { - debugLogger.error('Failed to restore original model:', error); - }); isSubmittingQueryRef.current = false; return; } @@ -1093,34 +1183,31 @@ export const useGeminiStream = ( addItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); } + // Only clear auto-retry countdown errors (those with an active timer). + // Do NOT clear static error+hint from handleErrorEvent — those should + // remain visible until the user presses Ctrl+Y to retry. + if (retryCountdownTimerRef.current) { + clearRetryCountdown(); + } if (loopDetectedRef.current) { loopDetectedRef.current = false; handleLoopDetectedEvent(); } - - // Restore original model if it was temporarily overridden - restoreOriginalModel().catch((error) => { - debugLogger.error('Failed to restore original model:', error); - }); } catch (error: unknown) { - // Restore original model if it was temporarily overridden - restoreOriginalModel().catch((error) => { - debugLogger.error('Failed to restore original model:', error); - }); - if (error instanceof UnauthorizedError) { onAuthError('Session expired or is unauthorized.'); } else if (!isNodeError(error) || error.name !== 'AbortError') { - addItem( - { - type: MessageType.ERROR, - text: parseAndFormatApiError( - getErrorMessage(error) || 'Unknown error', - config.getContentGeneratorConfig()?.authType, - ), - }, - userMessageTimestamp, - ); + lastPromptErroredRef.current = true; + const retryHint = t('Press Ctrl+Y to retry'); + // Store error with hint as a pending item (same as handleErrorEvent) + setPendingRetryErrorItem({ + type: 'error' as const, + text: parseAndFormatApiError( + getErrorMessage(error) || 'Unknown error', + config.getContentGeneratorConfig()?.authType, + ), + hint: retryHint, + }); } } finally { setIsResponding(false); @@ -1143,11 +1230,71 @@ export const useGeminiStream = ( startNewPrompt, getPromptCount, handleLoopDetectedEvent, - handleVisionSwitch, - restoreOriginalModel, + clearRetryCountdown, + pendingRetryCountdownItemRef, + setPendingRetryErrorItem, ], ); + /** + * Retries the last failed prompt when the user presses Ctrl+Y. + * + * Activation conditions for Ctrl+Y shortcut: + * 1. ✅ The last request must have failed (lastPromptErroredRef.current === true) + * 2. ✅ Current streaming state must NOT be "Responding" (avoid interrupting ongoing stream) + * 3. ✅ Current streaming state must NOT be "WaitingForConfirmation" (avoid conflicting with tool confirmation flow) + * 4. ✅ There must be a stored lastPrompt in lastPromptRef.current + * + * When conditions are not met: + * - If streaming is active (Responding/WaitingForConfirmation): silently return without action + * - If no failed request exists: display "No failed request to retry." info message + * + * When conditions are met: + * - Clears any pending auto-retry countdown to avoid duplicate retries + * - Re-submits the last query with skipPreparation: true for faster retry + * + * This function is exposed via UIActionsContext and triggered by InputPrompt + * when the user presses Ctrl+Y (bound to Command.RETRY_LAST in keyBindings.ts). + */ + const retryLastPrompt = useCallback(async () => { + if ( + streamingState === StreamingState.Responding || + streamingState === StreamingState.WaitingForConfirmation + ) { + return; + } + + const lastPrompt = lastPromptRef.current; + if (!lastPrompt || !lastPromptErroredRef.current) { + addItem( + { + type: MessageType.INFO, + text: t('No failed request to retry.'), + }, + Date.now(), + ); + return; + } + + // Commit the error to history (without hint) before clearing + const errorItem = pendingRetryErrorItemRef.current; + if (errorItem) { + addItem({ type: errorItem.type, text: errorItem.text }, Date.now()); + } + clearRetryCountdown(); + + await submitQuery(lastPrompt, { + isContinuation: false, + skipPreparation: true, + }); + }, [ + streamingState, + addItem, + clearRetryCountdown, + submitQuery, + pendingRetryErrorItemRef, + ]); + const handleApprovalModeChange = useCallback( async (newApprovalMode: ApprovalMode) => { // Auto-approve pending tool calls when switching to auto-approval modes @@ -1451,6 +1598,7 @@ export const useGeminiStream = ( pendingHistoryItems, thought, cancelOngoingRequest, + retryLastPrompt, pendingToolCalls: toolCalls, handleApprovalModeChange, activePtyId, diff --git a/packages/cli/src/ui/hooks/useVisionAutoSwitch.test.ts b/packages/cli/src/ui/hooks/useVisionAutoSwitch.test.ts deleted file mode 100644 index 782986ce9..000000000 --- a/packages/cli/src/ui/hooks/useVisionAutoSwitch.test.ts +++ /dev/null @@ -1,874 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import type { Part, PartListUnion } from '@google/genai'; -import { AuthType, type Config, ApprovalMode } from '@qwen-code/qwen-code-core'; - -// Mock the image format functions from core package -vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - isSupportedImageMimeType: vi.fn((mimeType: string) => - [ - 'image/png', - 'image/jpeg', - 'image/jpg', - 'image/gif', - 'image/webp', - ].includes(mimeType), - ), - getUnsupportedImageFormatWarning: vi.fn( - () => - 'Only the following image formats are supported: BMP, JPEG, JPG, PNG, TIFF, WEBP, HEIC. Other formats may not work as expected.', - ), - }; -}); -import { - shouldOfferVisionSwitch, - processVisionSwitchOutcome, - getVisionSwitchGuidanceMessage, - useVisionAutoSwitch, -} from './useVisionAutoSwitch.js'; -import { VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; -import { MessageType } from '../types.js'; -import { getDefaultVisionModel } from '../models/availableModels.js'; - -describe('useVisionAutoSwitch helpers', () => { - describe('shouldOfferVisionSwitch', () => { - it('returns false when authType is not QWEN_OAUTH', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.USE_GEMINI, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when current model is already a vision model', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'vision-model', - true, - ); - expect(result).toBe(false); - }); - - it('returns true when image parts exist, QWEN_OAUTH, and model is not vision', () => { - const parts: PartListUnion = [ - { text: 'hello' }, - { inlineData: { mimeType: 'image/jpeg', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(true); - }); - - it('detects image when provided as a single Part object (non-array)', () => { - const singleImagePart: PartListUnion = { - fileData: { mimeType: 'image/gif', fileUri: 'file://image.gif' }, - } as Part; - const result = shouldOfferVisionSwitch( - singleImagePart, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(true); - }); - - it('returns false when parts contain no images', () => { - const parts: PartListUnion = [{ text: 'just text' }]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when parts is a plain string', () => { - const parts: PartListUnion = 'plain text'; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when visionModelPreviewEnabled is false', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - false, - ); - expect(result).toBe(false); - }); - - it('returns true when image parts exist in YOLO mode context', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(true); - }); - - it('returns false when no image parts exist in YOLO mode context', () => { - const parts: PartListUnion = [{ text: 'just text' }]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when already using vision model in YOLO mode context', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'vision-model', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when authType is not QWEN_OAUTH in YOLO mode context', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.USE_GEMINI, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - }); - - describe('processVisionSwitchOutcome', () => { - it('maps SwitchOnce to a one-time model override', () => { - const vl = getDefaultVisionModel(); - const result = processVisionSwitchOutcome(VisionSwitchOutcome.SwitchOnce); - expect(result).toEqual({ modelOverride: vl }); - }); - - it('maps SwitchSessionToVL to a persistent session model', () => { - const vl = getDefaultVisionModel(); - const result = processVisionSwitchOutcome( - VisionSwitchOutcome.SwitchSessionToVL, - ); - expect(result).toEqual({ persistSessionModel: vl }); - }); - - it('maps ContinueWithCurrentModel to empty result', () => { - const result = processVisionSwitchOutcome( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - expect(result).toEqual({}); - }); - }); - - describe('getVisionSwitchGuidanceMessage', () => { - it('returns the expected guidance message', () => { - const vl = getDefaultVisionModel(); - const expected = - 'To use images with your query, you can:\n' + - `• Use /model set ${vl} to switch to a vision-capable model\n` + - '• Or remove the image and provide a text description instead'; - expect(getVisionSwitchGuidanceMessage()).toBe(expected); - }); - }); -}); - -describe('useVisionAutoSwitch hook', () => { - type AddItemFn = ( - item: { type: MessageType; text: string }, - ts: number, - ) => any; - - const createMockConfig = ( - authType: AuthType, - initialModel: string, - approvalMode: ApprovalMode = ApprovalMode.DEFAULT, - vlmSwitchMode?: string, - ) => { - let currentModel = initialModel; - const mockConfig: Partial = { - getModel: vi.fn(() => currentModel), - setModel: vi.fn(async (m: string) => { - currentModel = m; - }), - getApprovalMode: vi.fn(() => approvalMode), - getVlmSwitchMode: vi.fn(() => vlmSwitchMode), - getContentGeneratorConfig: vi.fn(() => ({ - authType, - model: currentModel, - apiKey: 'test-key', - vertexai: false, - })), - }; - return mockConfig as Config; - }; - - let addItem: AddItemFn; - - beforeEach(() => { - vi.clearAllMocks(); - addItem = vi.fn(); - }); - - it('returns shouldProceed=true immediately for continuations', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, vi.fn()), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, Date.now(), true); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(addItem).not.toHaveBeenCalled(); - }); - - it('does nothing when authType is not QWEN_OAUTH', async () => { - const config = createMockConfig(AuthType.USE_GEMINI, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 123, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('does nothing when there are no image parts', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [{ text: 'no images here' }]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 456, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('continues with current model when dialog returns empty result', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn().mockResolvedValue({}); // Empty result for ContinueWithCurrentModel - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - const userTs = 1010; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, userTs, false); - }); - - // Should not add any guidance message - expect(addItem).not.toHaveBeenCalledWith( - { type: MessageType.INFO, text: getVisionSwitchGuidanceMessage() }, - userTs, - ); - expect(res).toEqual({ shouldProceed: true }); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('applies a one-time override and returns originalModel, then restores', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig(AuthType.QWEN_OAUTH, initialModel); - const onVisionSwitchRequired = vi - .fn() - .mockResolvedValue({ modelOverride: 'coder-model' }); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 2020, false); - }); - - expect(res).toEqual({ shouldProceed: true, originalModel: initialModel }); - expect(config.setModel).toHaveBeenCalledWith('coder-model', { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (one-time override)', - }); - - // Now restore - await act(async () => { - await result.current.restoreOriginalModel(); - }); - expect(config.setModel).toHaveBeenLastCalledWith(initialModel, { - reason: 'vision_auto_switch', - context: 'Restoring original model after vision switch', - }); - }); - - it('persists session model when dialog requests persistence', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi - .fn() - .mockResolvedValue({ persistSessionModel: 'coder-model' }); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 3030, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(config.setModel).toHaveBeenCalledWith('coder-model', { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (session persistent)', - }); - - // Restore should be a no-op since no one-time override was used - await act(async () => { - await result.current.restoreOriginalModel(); - }); - // Last call should still be the persisted model set - expect((config.setModel as any).mock.calls.pop()?.[0]).toBe('coder-model'); - }); - - it('returns shouldProceed=true when dialog returns no special flags', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn().mockResolvedValue({}); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 4040, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('blocks when dialog throws or is cancelled', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn().mockRejectedValue(new Error('x')); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 5050, false); - }); - expect(res).toEqual({ shouldProceed: false }); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('does nothing when visionModelPreviewEnabled is false', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - false, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 6060, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - describe('YOLO mode behavior', () => { - it('automatically switches to vision model in YOLO mode without showing dialog', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig( - AuthType.QWEN_OAUTH, - initialModel, - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called in YOLO mode - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 7070, false); - }); - - // Should automatically switch without calling the dialog - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(res).toEqual({ - shouldProceed: true, - originalModel: initialModel, - }); - expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - }); - - it('does not switch in YOLO mode when no images are present', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [{ text: 'no images here' }]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 8080, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('does not switch in YOLO mode when already using vision model', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'vision-model', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 9090, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('restores original model after YOLO mode auto-switch', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig( - AuthType.QWEN_OAUTH, - initialModel, - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - // First, trigger the auto-switch - await act(async () => { - await result.current.handleVisionSwitch(parts, 10100, false); - }); - - // Verify model was switched - expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - - // Now restore the original model - await act(async () => { - await result.current.restoreOriginalModel(); - }); - - // Verify model was restored - expect(config.setModel).toHaveBeenLastCalledWith(initialModel, { - reason: 'vision_auto_switch', - context: 'Restoring original model after vision switch', - }); - }); - - it('does not switch in YOLO mode when authType is not QWEN_OAUTH', async () => { - const config = createMockConfig( - AuthType.USE_GEMINI, - 'qwen3-coder-plus', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 11110, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('does not switch in YOLO mode when visionModelPreviewEnabled is false', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - false, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 12120, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('handles multiple image formats in YOLO mode', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig( - AuthType.QWEN_OAUTH, - initialModel, - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { text: 'Here are some images:' }, - { inlineData: { mimeType: 'image/jpeg', data: '...' } }, - { fileData: { mimeType: 'image/png', fileUri: 'file://image.png' } }, - { text: 'Please analyze them.' }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 13130, false); - }); - - expect(res).toEqual({ - shouldProceed: true, - originalModel: initialModel, - }); - expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - }); - - describe('VLM switch mode default behavior', () => { - it('should automatically switch once when vlmSwitchMode is "once"', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'once', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBe('qwen3-coder-plus'); - expect(config.setModel).toHaveBeenCalledWith('vision-model', { - reason: 'vision_auto_switch', - context: 'Default VLM switch mode: once (one-time override)', - }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('should switch session when vlmSwitchMode is "session"', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'session', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBeUndefined(); // No original model for session switch - expect(config.setModel).toHaveBeenCalledWith('vision-model', { - reason: 'vision_auto_switch', - context: 'Default VLM switch mode: session (session persistent)', - }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('should continue with current model when vlmSwitchMode is "persist"', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'persist', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBeUndefined(); - expect(config.setModel).not.toHaveBeenCalled(); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('should fall back to user prompt when vlmSwitchMode is not set', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - undefined, // No default mode - ); - const onVisionSwitchRequired = vi - .fn() - .mockResolvedValue({ modelOverride: 'vision-model' }); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(onVisionSwitchRequired).toHaveBeenCalledWith(parts); - }); - - it('should fall back to persist behavior when vlmSwitchMode has invalid value', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'invalid-value', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBeUndefined(); - // For invalid values, it should continue with current model (persist behavior) - expect(config.setModel).not.toHaveBeenCalled(); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/cli/src/ui/hooks/useVisionAutoSwitch.ts b/packages/cli/src/ui/hooks/useVisionAutoSwitch.ts deleted file mode 100644 index f489c843a..000000000 --- a/packages/cli/src/ui/hooks/useVisionAutoSwitch.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { type PartListUnion, type Part } from '@google/genai'; -import { AuthType, type Config, ApprovalMode } from '@qwen-code/qwen-code-core'; -import { useCallback, useRef } from 'react'; -import { VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; -import { - getDefaultVisionModel, - isVisionModel, -} from '../models/availableModels.js'; -import { MessageType } from '../types.js'; -import type { UseHistoryManagerReturn } from './useHistoryManager.js'; -import { - isSupportedImageMimeType, - getUnsupportedImageFormatWarning, -} from '@qwen-code/qwen-code-core'; - -/** - * Checks if a PartListUnion contains image parts - */ -function hasImageParts(parts: PartListUnion): boolean { - if (typeof parts === 'string') { - return false; - } - - if (Array.isArray(parts)) { - return parts.some((part) => { - // Skip string parts - if (typeof part === 'string') return false; - return isImagePart(part); - }); - } - - // If it's a single Part (not a string), check if it's an image - if (typeof parts === 'object') { - return isImagePart(parts); - } - - return false; -} - -/** - * Checks if a single Part is an image part - */ -function isImagePart(part: Part): boolean { - // Check for inlineData with image mime type - if ('inlineData' in part && part.inlineData?.mimeType?.startsWith('image/')) { - return true; - } - - // Check for fileData with image mime type - if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) { - return true; - } - - return false; -} - -/** - * Checks if image parts have supported formats and returns unsupported ones - */ -function checkImageFormatsSupport(parts: PartListUnion): { - hasImages: boolean; - hasUnsupportedFormats: boolean; - unsupportedMimeTypes: string[]; -} { - const unsupportedMimeTypes: string[] = []; - let hasImages = false; - - if (typeof parts === 'string') { - return { - hasImages: false, - hasUnsupportedFormats: false, - unsupportedMimeTypes: [], - }; - } - - const partsArray = Array.isArray(parts) ? parts : [parts]; - - for (const part of partsArray) { - if (typeof part === 'string') continue; - - let mimeType: string | undefined; - - // Check inlineData - if ( - 'inlineData' in part && - part.inlineData?.mimeType?.startsWith('image/') - ) { - hasImages = true; - mimeType = part.inlineData.mimeType; - } - - // Check fileData - if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) { - hasImages = true; - mimeType = part.fileData.mimeType; - } - - // Check if the mime type is supported - if (mimeType && !isSupportedImageMimeType(mimeType)) { - unsupportedMimeTypes.push(mimeType); - } - } - - return { - hasImages, - hasUnsupportedFormats: unsupportedMimeTypes.length > 0, - unsupportedMimeTypes, - }; -} - -/** - * Determines if we should offer vision switch for the given parts, auth type, and current model - */ -export function shouldOfferVisionSwitch( - parts: PartListUnion, - authType: AuthType, - currentModel: string, - visionModelPreviewEnabled: boolean = true, -): boolean { - // Only trigger for qwen-oauth - if (authType !== AuthType.QWEN_OAUTH) { - return false; - } - - // If vision model preview is disabled, never offer vision switch - if (!visionModelPreviewEnabled) { - return false; - } - - // If current model is already a vision model, no need to switch - if (isVisionModel(currentModel)) { - return false; - } - - // Check if the current message contains image parts - return hasImageParts(parts); -} - -/** - * Interface for vision switch result - */ -export interface VisionSwitchResult { - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; -} - -/** - * Processes the vision switch outcome and returns the appropriate result - */ -export function processVisionSwitchOutcome( - outcome: VisionSwitchOutcome, -): VisionSwitchResult { - const vlModelId = getDefaultVisionModel(); - - switch (outcome) { - case VisionSwitchOutcome.SwitchOnce: - return { modelOverride: vlModelId }; - - case VisionSwitchOutcome.SwitchSessionToVL: - return { persistSessionModel: vlModelId }; - - case VisionSwitchOutcome.ContinueWithCurrentModel: - return {}; // Continue with current model, no changes needed - - default: - return {}; // Default to continuing with current model - } -} - -/** - * Gets the guidance message for when vision switch is disallowed - */ -export function getVisionSwitchGuidanceMessage(): string { - const vlModelId = getDefaultVisionModel(); - return `To use images with your query, you can: -• Use /model set ${vlModelId} to switch to a vision-capable model -• Or remove the image and provide a text description instead`; -} - -/** - * Interface for vision switch handling result - */ -export interface VisionSwitchHandlingResult { - shouldProceed: boolean; - originalModel?: string; -} - -/** - * Custom hook for handling vision model auto-switching - */ -export function useVisionAutoSwitch( - config: Config, - addItem: UseHistoryManagerReturn['addItem'], - visionModelPreviewEnabled: boolean = true, - onVisionSwitchRequired?: (query: PartListUnion) => Promise<{ - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }>, -) { - const originalModelRef = useRef(null); - - const handleVisionSwitch = useCallback( - async ( - query: PartListUnion, - userMessageTimestamp: number, - isContinuation: boolean, - ): Promise => { - // Skip vision switch handling for continuations or if no handler provided - if (isContinuation || !onVisionSwitchRequired) { - return { shouldProceed: true }; - } - - const contentGeneratorConfig = config.getContentGeneratorConfig(); - - // Only handle qwen-oauth auth type - if (contentGeneratorConfig?.authType !== AuthType.QWEN_OAUTH) { - return { shouldProceed: true }; - } - - // Check image format support first - const formatCheck = checkImageFormatsSupport(query); - - // If there are unsupported image formats, show warning - if (formatCheck.hasUnsupportedFormats) { - addItem( - { - type: MessageType.INFO, - text: getUnsupportedImageFormatWarning(), - }, - userMessageTimestamp, - ); - // Continue processing but with warning shown - } - - // Check if vision switch is needed - if ( - !shouldOfferVisionSwitch( - query, - contentGeneratorConfig.authType, - config.getModel(), - visionModelPreviewEnabled, - ) - ) { - return { shouldProceed: true }; - } - - // In YOLO mode, automatically switch to vision model without user interaction - if (config.getApprovalMode() === ApprovalMode.YOLO) { - const vlModelId = getDefaultVisionModel(); - originalModelRef.current = config.getModel(); - await config.setModel(vlModelId, { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - return { - shouldProceed: true, - originalModel: originalModelRef.current, - }; - } - - // Check if there's a default VLM switch mode configured - const defaultVlmSwitchMode = config.getVlmSwitchMode(); - if (defaultVlmSwitchMode) { - // Convert string value to VisionSwitchOutcome enum - let outcome: VisionSwitchOutcome; - switch (defaultVlmSwitchMode) { - case 'once': - outcome = VisionSwitchOutcome.SwitchOnce; - break; - case 'session': - outcome = VisionSwitchOutcome.SwitchSessionToVL; - break; - case 'persist': - outcome = VisionSwitchOutcome.ContinueWithCurrentModel; - break; - default: - // Invalid value, fall back to prompting user - outcome = VisionSwitchOutcome.ContinueWithCurrentModel; - } - - // Process the default outcome - const visionSwitchResult = processVisionSwitchOutcome(outcome); - - if (visionSwitchResult.modelOverride) { - // One-time model override - originalModelRef.current = config.getModel(); - await config.setModel(visionSwitchResult.modelOverride, { - reason: 'vision_auto_switch', - context: `Default VLM switch mode: ${defaultVlmSwitchMode} (one-time override)`, - }); - return { - shouldProceed: true, - originalModel: originalModelRef.current, - }; - } else if (visionSwitchResult.persistSessionModel) { - // Persistent session model change - await config.setModel(visionSwitchResult.persistSessionModel, { - reason: 'vision_auto_switch', - context: `Default VLM switch mode: ${defaultVlmSwitchMode} (session persistent)`, - }); - return { shouldProceed: true }; - } - - // For ContinueWithCurrentModel or any other case, proceed with current model - return { shouldProceed: true }; - } - - try { - const visionSwitchResult = await onVisionSwitchRequired(query); - - if (visionSwitchResult.modelOverride) { - // One-time model override - originalModelRef.current = config.getModel(); - await config.setModel(visionSwitchResult.modelOverride, { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (one-time override)', - }); - return { - shouldProceed: true, - originalModel: originalModelRef.current, - }; - } else if (visionSwitchResult.persistSessionModel) { - // Persistent session model change - await config.setModel(visionSwitchResult.persistSessionModel, { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (session persistent)', - }); - return { shouldProceed: true }; - } - - // For ContinueWithCurrentModel or any other case, proceed with current model - return { shouldProceed: true }; - } catch (_error) { - // If vision switch dialog was cancelled or errored, don't proceed - return { shouldProceed: false }; - } - }, - [config, addItem, visionModelPreviewEnabled, onVisionSwitchRequired], - ); - - const restoreOriginalModel = useCallback(async () => { - if (originalModelRef.current) { - await config.setModel(originalModelRef.current, { - reason: 'vision_auto_switch', - context: 'Restoring original model after vision switch', - }); - originalModelRef.current = null; - } - }, [config]); - - return { - handleVisionSwitch, - restoreOriginalModel, - }; -} diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 15d45fdab..8961f9ff7 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -59,6 +59,7 @@ describe('keyMatchers', () => { [Command.QUIT]: (key: Key) => key.ctrl && key.name === 'c', [Command.EXIT]: (key: Key) => key.ctrl && key.name === 'd', [Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's', + [Command.RETRY_LAST]: (key: Key) => key.ctrl && key.name === 'y', [Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r', [Command.SUBMIT_REVERSE_SEARCH]: (key: Key) => key.name === 'return' && !key.ctrl, @@ -252,6 +253,11 @@ describe('keyMatchers', () => { positive: [createKey('s', { ctrl: true })], negative: [createKey('s'), createKey('l', { ctrl: true })], }, + { + command: Command.RETRY_LAST, + positive: [createKey('y', { ctrl: true })], + negative: [createKey('y'), createKey('r', { ctrl: true })], + }, // Shell commands { diff --git a/packages/cli/src/ui/models/availableModels.test.ts b/packages/cli/src/ui/models/availableModels.test.ts index feac835c6..767fb6f06 100644 --- a/packages/cli/src/ui/models/availableModels.test.ts +++ b/packages/cli/src/ui/models/availableModels.test.ts @@ -9,42 +9,30 @@ import { getAvailableModelsForAuthType, getFilteredQwenModels, getOpenAIAvailableModelFromEnv, - isVisionModel, - getDefaultVisionModel, - AVAILABLE_MODELS_QWEN, - MAINLINE_VLM, - MAINLINE_CODER, } from './availableModels.js'; import { AuthType, type Config } from '@qwen-code/qwen-code-core'; describe('availableModels', () => { - describe('AVAILABLE_MODELS_QWEN', () => { - it('should include coder model', () => { - const coderModel = AVAILABLE_MODELS_QWEN.find( - (m) => m.id === MAINLINE_CODER, - ); - expect(coderModel).toBeDefined(); - expect(coderModel?.isVision).toBeFalsy(); + describe('Qwen models', () => { + const qwenModels = getFilteredQwenModels(); + + it('should include only coder-model', () => { + expect(qwenModels.length).toBe(1); + expect(qwenModels[0].id).toBe('coder-model'); }); - it('should include vision model', () => { - const visionModel = AVAILABLE_MODELS_QWEN.find( - (m) => m.id === MAINLINE_VLM, - ); - expect(visionModel).toBeDefined(); - expect(visionModel?.isVision).toBe(true); + it('should have coder-model with vision capability', () => { + const coderModel = qwenModels[0]; + expect(coderModel.isVision).toBe(true); }); }); describe('getFilteredQwenModels', () => { - it('should return all models when vision preview is enabled', () => { - const models = getFilteredQwenModels(true); - expect(models.length).toBe(AVAILABLE_MODELS_QWEN.length); - }); - - it('should filter out vision models when preview is disabled', () => { - const models = getFilteredQwenModels(false); - expect(models.every((m) => !m.isVision)).toBe(true); + it('should return coder-model with vision capability', () => { + const models = getFilteredQwenModels(); + expect(models.length).toBe(1); + expect(models[0].id).toBe('coder-model'); + expect(models[0].isVision).toBe(true); }); }); @@ -91,23 +79,36 @@ describe('availableModels', () => { it('should return hard-coded qwen models for qwen-oauth', () => { const models = getAvailableModelsForAuthType(AuthType.QWEN_OAUTH); - expect(models).toEqual(AVAILABLE_MODELS_QWEN); + expect(models.length).toBe(1); + expect(models[0].id).toBe('coder-model'); + expect(models[0].isVision).toBe(true); }); - it('should return hard-coded qwen models even when config is provided', () => { + it('should use config models for qwen-oauth when config is provided', () => { const mockConfig = { - getAvailableModels: vi - .fn() - .mockReturnValue([ - { id: 'custom', label: 'Custom', authType: AuthType.QWEN_OAUTH }, - ]), + getAvailableModelsForAuthType: vi.fn().mockReturnValue([ + { + id: 'custom', + label: 'Custom', + description: 'Custom model', + authType: AuthType.QWEN_OAUTH, + isVision: false, + }, + ]), } as unknown as Config; const models = getAvailableModelsForAuthType( AuthType.QWEN_OAUTH, mockConfig, ); - expect(models).toEqual(AVAILABLE_MODELS_QWEN); + expect(models).toEqual([ + { + id: 'custom', + label: 'Custom', + description: 'Custom model', + isVision: false, + }, + ]); }); it('should use config.getAvailableModels for openai authType when available', () => { @@ -182,24 +183,4 @@ describe('availableModels', () => { expect(models).toEqual([]); }); }); - - describe('isVisionModel', () => { - it('should return true for vision model', () => { - expect(isVisionModel(MAINLINE_VLM)).toBe(true); - }); - - it('should return false for non-vision model', () => { - expect(isVisionModel(MAINLINE_CODER)).toBe(false); - }); - - it('should return false for unknown model', () => { - expect(isVisionModel('unknown-model')).toBe(false); - }); - }); - - describe('getDefaultVisionModel', () => { - it('should return the vision model ID', () => { - expect(getDefaultVisionModel()).toBe(MAINLINE_VLM); - }); - }); }); diff --git a/packages/cli/src/ui/models/availableModels.ts b/packages/cli/src/ui/models/availableModels.ts index 0b9727642..def4f12a7 100644 --- a/packages/cli/src/ui/models/availableModels.ts +++ b/packages/cli/src/ui/models/availableModels.ts @@ -6,9 +6,9 @@ import { AuthType, - DEFAULT_QWEN_MODEL, type Config, type AvailableModel as CoreAvailableModel, + QWEN_OAUTH_MODELS, } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; @@ -19,41 +19,25 @@ export type AvailableModel = { isVision?: boolean; }; -export const MAINLINE_VLM = 'vision-model'; -export const MAINLINE_CODER = DEFAULT_QWEN_MODEL; +const CACHED_QWEN_OAUTH_MODELS: AvailableModel[] = QWEN_OAUTH_MODELS.map( + (model) => ({ + id: model.id, + label: model.name ?? model.id, + description: model.description, + isVision: model.capabilities?.vision ?? false, + }), +); -export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [ - { - id: MAINLINE_CODER, - label: MAINLINE_CODER, - get description() { - return t( - 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance', - ); - }, - }, - { - id: MAINLINE_VLM, - label: MAINLINE_VLM, - get description() { - return t( - 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)', - ); - }, - isVision: true, - }, -]; +function getQwenOAuthModels(): readonly AvailableModel[] { + return CACHED_QWEN_OAUTH_MODELS; +} /** - * Get available Qwen models filtered by vision model preview setting + * Get available Qwen models + * coder-model now has vision capabilities by default. */ -export function getFilteredQwenModels( - visionModelPreviewEnabled: boolean, -): AvailableModel[] { - if (visionModelPreviewEnabled) { - return AVAILABLE_MODELS_QWEN; - } - return AVAILABLE_MODELS_QWEN.filter((model) => !model.isVision); +export function getFilteredQwenModels(): AvailableModel[] { + return [...getQwenOAuthModels()]; } /** @@ -104,18 +88,12 @@ function convertCoreModelToCliModel( * Get available models for the given authType. * * If a Config object is provided, uses config.getAvailableModelsForAuthType(). - * For qwen-oauth, always returns the hard-coded models. * Falls back to environment variables only when no config is provided. */ export function getAvailableModelsForAuthType( authType: AuthType, config?: Config, ): AvailableModel[] { - // For qwen-oauth, always use hard-coded models, this aligns with the API gateway. - if (authType === AuthType.QWEN_OAUTH) { - return AVAILABLE_MODELS_QWEN; - } - // Use config's model registry when available if (config) { try { @@ -134,6 +112,9 @@ export function getAvailableModelsForAuthType( // Fall back to environment variables for specific auth types (no config provided) switch (authType) { + case AuthType.QWEN_OAUTH: { + return [...getQwenOAuthModels()]; + } case AuthType.USE_OPENAI: { const openAIModel = getOpenAIAvailableModelFromEnv(); return openAIModel ? [openAIModel] : []; @@ -146,17 +127,3 @@ export function getAvailableModelsForAuthType( return []; } } - -/** - * Hard code the default vision model as a string literal, - * until our coding model supports multimodal. - */ -export function getDefaultVisionModel(): string { - return MAINLINE_VLM; -} - -export function isVisionModel(modelId: string): boolean { - return AVAILABLE_MODELS_QWEN.some( - (model) => model.id === modelId && model.isVision, - ); -} diff --git a/packages/cli/src/ui/themes/no-color.ts b/packages/cli/src/ui/themes/no-color.ts index 3d5b4d4e7..c3a7cbce4 100644 --- a/packages/cli/src/ui/themes/no-color.ts +++ b/packages/cli/src/ui/themes/no-color.ts @@ -33,6 +33,7 @@ const noColorSemanticColors: SemanticColors = { secondary: '', link: '', accent: '', + code: '', }, background: { primary: '', diff --git a/packages/cli/src/ui/themes/semantic-tokens.ts b/packages/cli/src/ui/themes/semantic-tokens.ts index 2aa27a09c..d3047f0f0 100644 --- a/packages/cli/src/ui/themes/semantic-tokens.ts +++ b/packages/cli/src/ui/themes/semantic-tokens.ts @@ -12,6 +12,7 @@ export interface SemanticColors { secondary: string; link: string; accent: string; + code: string; }; background: { primary: string; @@ -45,6 +46,7 @@ export const lightSemanticColors: SemanticColors = { secondary: lightTheme.Gray, link: lightTheme.AccentBlue, accent: lightTheme.AccentPurple, + code: lightTheme.LightBlue, }, background: { primary: lightTheme.Background, @@ -77,6 +79,7 @@ export const darkSemanticColors: SemanticColors = { secondary: darkTheme.Gray, link: darkTheme.AccentBlue, accent: darkTheme.AccentPurple, + code: darkTheme.LightBlue, }, background: { primary: darkTheme.Background, @@ -109,6 +112,7 @@ export const ansiSemanticColors: SemanticColors = { secondary: ansiTheme.Gray, link: ansiTheme.AccentBlue, accent: ansiTheme.AccentPurple, + code: ansiTheme.LightBlue, }, background: { primary: ansiTheme.Background, diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts index 3ae3bbead..5fee07729 100644 --- a/packages/cli/src/ui/themes/theme.ts +++ b/packages/cli/src/ui/themes/theme.ts @@ -40,6 +40,7 @@ export interface CustomTheme { secondary?: string; link?: string; accent?: string; + code?: string; }; background?: { primary?: string; @@ -174,6 +175,7 @@ export class Theme { secondary: this.colors.Gray, link: this.colors.AccentBlue, accent: this.colors.AccentPurple, + code: this.colors.LightBlue, }, background: { primary: this.colors.Background, @@ -269,7 +271,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme { type: 'custom', Background: customTheme.background?.primary ?? customTheme.Background ?? '', Foreground: customTheme.text?.primary ?? customTheme.Foreground ?? '', - LightBlue: customTheme.text?.link ?? customTheme.LightBlue ?? '', + LightBlue: customTheme.text?.code ?? customTheme.LightBlue ?? '', AccentBlue: customTheme.text?.link ?? customTheme.AccentBlue ?? '', AccentPurple: customTheme.text?.accent ?? customTheme.AccentPurple ?? '', AccentCyan: customTheme.text?.link ?? customTheme.AccentCyan ?? '', @@ -433,6 +435,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme { secondary: customTheme.text?.secondary ?? colors.Gray, link: customTheme.text?.link ?? colors.AccentBlue, accent: customTheme.text?.accent ?? colors.AccentPurple, + code: customTheme.text?.code ?? colors.LightBlue, }, background: { primary: customTheme.background?.primary ?? colors.Background, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index ae799bfa6..d2483f371 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -121,6 +121,7 @@ export type HistoryItemInfo = HistoryItemBase & { export type HistoryItemError = HistoryItemBase & { type: 'error'; text: string; + hint?: string; // Optional inline hint (e.g., retry countdown) displayed in secondary color }; export type HistoryItemWarning = HistoryItemBase & { @@ -256,6 +257,11 @@ export type HistoryItemMcpStatus = HistoryItemBase & { showTips: boolean; }; +export type HistoryItemInsightProgress = HistoryItemBase & { + type: 'insight_progress'; + progress: InsightProgressProps; +}; + // Using Omit seems to have some issues with typescript's // type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that // 'tools' in historyItem. @@ -284,7 +290,8 @@ export type HistoryItemWithoutId = | HistoryItemExtensionsList | HistoryItemToolsList | HistoryItemSkillsList - | HistoryItemMcpStatus; + | HistoryItemMcpStatus + | HistoryItemInsightProgress; export type HistoryItem = HistoryItemWithoutId & { id: number }; @@ -307,6 +314,15 @@ export enum MessageType { TOOLS_LIST = 'tools_list', SKILLS_LIST = 'skills_list', MCP_STATUS = 'mcp_status', + INSIGHT_PROGRESS = 'insight_progress', +} + +export interface InsightProgressProps { + stage: string; + progress: number; + detail?: string; + isComplete?: boolean; + error?: string; } // Simplified message structure for internal feedback @@ -373,6 +389,11 @@ export type Message = type: MessageType.SUMMARY; summary: SummaryProps; timestamp: Date; + } + | { + type: MessageType.INSIGHT_PROGRESS; + progress: InsightProgressProps; + timestamp: Date; }; export interface ConsoleMessageItem { diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx index 0dabddb22..da0d99132 100644 --- a/packages/cli/src/ui/utils/CodeColorizer.tsx +++ b/packages/cli/src/ui/utils/CodeColorizer.tsx @@ -125,6 +125,7 @@ export function colorizeLine( * * @param code The code string to highlight. * @param language The language identifier (e.g., 'javascript', 'css', 'html') + * @param tabWidth The number of spaces to replace each tab character with, default is 4 * @returns A React.ReactNode containing Ink elements for the highlighted code. */ export function colorizeCode( @@ -134,8 +135,11 @@ export function colorizeCode( maxWidth?: number, theme?: Theme, settings?: LoadedSettings, + tabWidth = 4, ): React.ReactNode { - const codeToHighlight = code.replace(/\n$/, ''); + const codeToHighlight = code + .replace(/\n$/, '') + .replace(/\t/g, ' '.repeat(tabWidth)); const activeTheme = theme || themeManager.getActiveTheme(); const showLineNumbers = settings?.merged.ui?.showLineNumbers ?? true; diff --git a/packages/cli/src/ui/utils/export/formatters/html.ts b/packages/cli/src/ui/utils/export/formatters/html.ts index fe25ac633..b4b72fb39 100644 --- a/packages/cli/src/ui/utils/export/formatters/html.ts +++ b/packages/cli/src/ui/utils/export/formatters/html.ts @@ -5,7 +5,7 @@ */ import type { ExportSessionData } from '../types.js'; -import { HTML_TEMPLATE } from './htmlTemplate.js'; +import { EXPORT_HTML_TEMPLATE as HTML_TEMPLATE } from '@qwen-code/web-templates'; /** * Escapes JSON for safe embedding in HTML. diff --git a/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts b/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts deleted file mode 100644 index c553d3f8c..000000000 --- a/packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * This HTML template is code-generated; do not edit manually. - */ - -export const HTML_TEMPLATE = - '\n\n \n \n \n