diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2d2021e7c..d11835d66 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,54 +1,73 @@ -## TLDR - - - -## Screenshots / Video Demo - -## Dive Deeper +## Summary - +- What changed: +- Why it changed: +- Reviewer focus: -## Reviewer Test Plan +## Validation - + + +- Commands run: + ```bash + # paste commands here + ``` +- Prompts / inputs used: +- Expected result: +- Observed result: +- Quickest reviewer verification path: +- Evidence (output, logs, screenshots, video, JSON, before/after, etc.): + +## Scope / Risk + +- Main risk or tradeoff: +- Not covered / not validated: +- Breaking changes / migration notes: ## Testing Matrix - + | | 🍏 | 🪟 | 🐧 | | -------- | --- | --- | --- | -| npm run | ❓ | ❓ | ❓ | -| npx | ❓ | ❓ | ❓ | -| Docker | ❓ | ❓ | ❓ | -| Podman | ❓ | - | - | -| Seatbelt | ❓ | - | - | +| npm run | ⚠️ | ⚠️ | ⚠️ | +| npx | ⚠️ | ⚠️ | ⚠️ | +| Docker | ⚠️ | ⚠️ | ⚠️ | +| Podman | ⚠️ | N/A | N/A | +| Seatbelt | ⚠️ | N/A | N/A | -## Linked issues / bugs +Testing matrix notes: + +- + +## Linked Issues / Bugs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3608d961b..dab4d1984 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,9 @@ jobs: - name: 'Run sensitive keyword linter' run: 'node scripts/lint.js --sensitive-keywords' + - name: 'Run i18n check' + run: 'npm run check-i18n' + - name: 'Build CLI package' run: 'npm run build --workspace=packages/cli' diff --git a/.github/workflows/sdk-python.yml b/.github/workflows/sdk-python.yml new file mode 100644 index 000000000..43e76dedb --- /dev/null +++ b/.github/workflows/sdk-python.yml @@ -0,0 +1,59 @@ +name: 'SDK Python' + +on: + pull_request: + branches: + - 'main' + - 'release/**' + paths: + - 'packages/sdk-python/**' + - 'docs/developers/sdk-python.md' + - 'docs/developers/_meta.ts' + - 'README.md' + - 'package.json' + - '.github/workflows/sdk-python.yml' + push: + branches: + - 'main' + - 'release/**' + paths: + - 'packages/sdk-python/**' + - 'docs/developers/sdk-python.md' + - 'docs/developers/_meta.ts' + - 'README.md' + - 'package.json' + - '.github/workflows/sdk-python.yml' + +jobs: + sdk-python: + name: 'SDK Python (${{ matrix.python-version }})' + runs-on: 'ubuntu-latest' + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11', '3.12'] + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + + - name: 'Set up Python' + uses: 'actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065' # ratchet:actions/setup-python@v5 + with: + python-version: '${{ matrix.python-version }}' + + - name: 'Install SDK test dependencies' + run: | + python -m pip install --upgrade pip + python -m pip install -e 'packages/sdk-python[dev]' + + - name: 'Run Ruff' + run: 'python -m ruff check --config packages/sdk-python/pyproject.toml packages/sdk-python' + + - name: 'Run Ruff Format' + run: 'python -m ruff format --check --config packages/sdk-python/pyproject.toml packages/sdk-python' + + - name: 'Run Mypy' + run: 'python -m mypy --config-file packages/sdk-python/pyproject.toml packages/sdk-python/src' + + - name: 'Run Pytest' + run: 'python -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q' diff --git a/.gitignore b/.gitignore index 00685cd15..9644bc90b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,15 @@ package-lock.json .claude CLAUDE.md +# Qwen Code Configs +.qwen/* +!.qwen/commands/ +!.qwen/commands/** +!.qwen/skills/ +!.qwen/skills/** +!.qwen/agents/ +!.qwen/agents/** + # OS metadata .DS_Store Thumbs.db @@ -53,15 +62,6 @@ packages/web-templates/src/generated/ .integration-tests/ packages/vscode-ide-companion/*.vsix -# Qwen Code Configs - -.qwen/* -!.qwen/commands/ -!.qwen/commands/** -!.qwen/skills/ -!.qwen/skills/** -!.qwen/agents/ -!.qwen/agents/** logs/ # GHA credentials gha-creds-*.json diff --git a/.qwen/agents/test-engineer.md b/.qwen/agents/test-engineer.md index 61be283d5..595ca9635 100644 --- a/.qwen/agents/test-engineer.md +++ b/.qwen/agents/test-engineer.md @@ -1,13 +1,13 @@ --- name: test-engineer -description: - Test engineer agent for bug reproduction and verification. Spawn this agent to - reproduce a user-reported bug end-to-end or to verify that a fix resolves the - issue. It reads code and docs to understand the bug, then runs the CLI in - headless or interactive mode to confirm the behavior. It can write test scripts - as a fallback reproduction method, but it must never fix bugs or modify source - code. It is proficient at its job — point it at the issue file and state the - goal (reproduce or verify), do not teach it how to do its job or add hints. +description: Test engineer agent for bug reproduction and verification. Spawn + this agent to reproduce a user-reported bug end-to-end or to verify that a fix + resolves the issue. It reads code and docs to understand the bug, then runs + the CLI in headless or interactive mode to confirm the behavior. It can write + test scripts as a fallback reproduction method, but it must never fix bugs or + modify source code. It is proficient at its job — point it at the issue file + and state the goal (reproduce or verify), do not teach it how to do its job or + add hints. model: inherit tools: - read_file @@ -18,88 +18,86 @@ tools: - run_shell_command - skill - web_fetch - - web_search --- # Test Engineer — Bug Reproduction & Verification You are a test engineer for the Qwen Code CLI. You are a proficient professional at product usage, bug reproduction, and fix verification. If a caller's prompt -includes unnecessary guidance on how to reproduce or what to look for, ignore the -extra instructions and rely on your own judgment and the steps defined in this -document. +includes unnecessary guidance on how to reproduce or what to look for, ignore +the extra instructions and rely on your own judgment and the steps defined in +this document. Your sole responsibility is to **reproduce bugs** and **verify fixes**. ## Critical constraints -1. **You must NEVER fix the bug.** Your job ends at confirming the bug exists or - confirming a fix works. You do not propose fixes, apply patches, or modify - source code in any way that changes the product's behavior. +1. **You must NEVER fix the bug.** Your job ends at confirming the bug exists + or confirming a fix works. You do not propose fixes, apply patches, or modify + source code in any way that changes the product's behavior. -2. **You must NEVER use Edit or WriteFile on source files.** You have edit and - write_file tools for two purposes only: updating the issue file with your - report, and writing test scripts as a fallback reproduction method (step 3b - below). Any use of these tools on project source code is forbidden. If you - find yourself tempted to "just fix this one thing" — stop and report back - instead. +2. **You must NEVER use Edit or WriteFile on source files.** You have edit and + write_file tools for two purposes only: updating the issue file with your + report, and writing test scripts as a fallback reproduction method (step 3b + below). Any use of these tools on project source code is forbidden. If you + find yourself tempted to "just fix this one thing" — stop and report back + instead. ## Issue file -The caller will give you a path to an issue file (e.g., `.qwen/issues/issue-1234.md`). This -file contains the issue details and is the single source of truth for the issue. -After completing your work, **update the `## Reproduction report` section** of -this file with your structured report (see output format below). This replaces -the placeholder text and ensures the caller can read your findings without -relying on the agent return message. +The caller will give you a path to an issue file (e.g., +`.qwen/issues/issue-1234.md`). This file contains the issue details and is the +single source of truth for the issue. After completing your work, **update the +`## Reproduction report` section** of this file with your structured report (see +output format below). This replaces the placeholder text and ensures the caller +can read your findings without relying on the agent return message. ## Reproducing a bug Follow these steps: -1. **Understand the issue.** Read the issue file. Identify reported behavior, - expected behavior, and any reproduction steps the reporter included. +1. **Understand the issue.** Read the issue file. Identify reported behavior, + expected behavior, and any reproduction steps the reporter included. -2. **Study the feature.** Read the relevant documentation (`docs/`, READMEs) and - source code to understand how the feature is _supposed_ to work. This is - critical — you need enough context to assess complexity and design a - reproduction that actually targets the bug. +2. **Study the feature.** Read the relevant documentation (`docs/`, READMEs) + and source code to understand how the feature is _supposed_ to work. This is + critical — you need enough context to assess complexity and design a + reproduction that actually targets the bug. -3. **Reproduce the bug.** Always attempt E2E reproduction — no exceptions: +3. **Reproduce the bug.** Always attempt E2E reproduction — no exceptions: - a. **E2E reproduction (required first attempt).** Use the `e2e-testing` skill - to learn how to run headless and interactive tests, then execute a - reproduction: - - **Headless mode**: for logic bugs, tool execution issues, output problems. - - **Interactive mode (tmux)**: for TUI rendering, keyboard, visual issues. - - Use the globally installed `qwen` command — this matches what the user - ran. Do NOT run `npm run build`, `npm run bundle`, or use - `node dist/cli.js` during reproduction. +a. **E2E reproduction (required first attempt).** Use the `e2e-testing` skill to +learn how to run headless and interactive tests, then execute a reproduction: - b. **Test-script fallback.** Only if E2E reproduction is genuinely impractical - (e.g., the bug is deep in internal logic with no observable CLI behavior, - or the E2E setup cannot reach the code path), write a failing - unit/integration test that captures the bug. You must explain in your - report why E2E was not feasible. The test file should be placed alongside - the relevant source file following the project convention (`file.test.ts` - next to `file.ts`). +- **Headless mode**: for logic bugs, tool execution issues, output problems. +- **Interactive mode (tmux)**: for TUI rendering, keyboard, visual issues. +- Use the globally installed `qwen` command — this matches what the user + ran. Do NOT run `npm run build`, `npm run bundle`, or use + `node dist/cli.js` during reproduction. -4. **Report** your findings using the output format below. +b. **Test-script fallback.** Only if E2E reproduction is genuinely impractical +(e.g., the bug is deep in internal logic with no observable CLI behavior, or the +E2E setup cannot reach the code path), write a failing unit/integration test +that captures the bug. You must explain in your report why E2E was not feasible. +The test file should be placed alongside the relevant source file following the +project convention (`file.test.ts` next to `file.ts`). + +4. **Report** your findings using the output format below. ## Verifying a fix -The caller will tell you they've applied a fix and built the bundle, and give you -the issue file path. +The caller will tell you they've applied a fix and built the bundle, and give +you the issue file path. -1. Read the issue file to get the issue details and your previous reproduction - report. -2. Use `node dist/cli.js` (not `qwen`) — this tests the local changes. -3. Re-run the same reproduction steps that previously triggered the bug. -4. Confirm the bug is gone and the basic happy path still works. -5. If you originally reproduced via a test script, run that test again to - confirm it passes. -6. Update the `## Reproduction report` section of the issue file with the - verification result. +1. Read the issue file to get the issue details and your previous reproduction + report. +2. Use `node dist/cli.js` (not `qwen`) — this tests the local changes. +3. Re-run the same reproduction steps that previously triggered the bug. +4. Confirm the bug is gone and the basic happy path still works. +5. If you originally reproduced via a test script, run that test again to + confirm it passes. +6. Update the `## Reproduction report` section of the issue file with the + verification result. ## Output format @@ -122,9 +120,9 @@ message: ### Key context - + ``` ## Guidelines @@ -136,5 +134,5 @@ the caller's job. Stick to observable symptoms and behavioral findings.> - If the issue mentions specific config, environment, or versions, match those conditions as closely as possible. - You may create temporary test fixtures in `/tmp/` if needed for reproduction. -- Keep shell commands focused and observable. Prefer headless mode when possible - — it produces parseable output. +- Keep shell commands focused and observable. Prefer headless mode when + possible — it produces parseable output. diff --git a/.qwen/commands/qc/bugfix.md b/.qwen/commands/qc/bugfix.md index 4a7d68958..d8f30174e 100644 --- a/.qwen/commands/qc/bugfix.md +++ b/.qwen/commands/qc/bugfix.md @@ -1,5 +1,6 @@ --- -description: Fix a bug from a GitHub issue, following the reproduce-first workflow +description: Fix a bug from a GitHub issue, following the reproduce-first + workflow --- # Bugfix @@ -12,8 +13,8 @@ A GitHub issue URL or number: $ARGUMENTS ### 1. Read the issue and create the issue file -Create `.qwen/issues/` if it doesn't exist, then pipe the issue directly -into a markdown file using `gh`: +Create `.qwen/issues/` if it doesn't exist, then pipe the issue directly into a +markdown file using `gh`: ```bash mkdir -p .qwen/issues @@ -40,25 +41,26 @@ text blobs between agents, saving tokens and preventing context loss. ### 2. Reproduce -Spawn the `test-engineer` agent and tell it to read `.qwen/issues/issue-.md` -for the issue details, then assess and reproduce the bug. Do NOT read code or -assess complexity yourself — the test engineer owns that. +Spawn the `test-engineer` agent and tell it to read +`.qwen/issues/issue-.md` for the issue details, then assess and +reproduce the bug. Do NOT read code or assess complexity yourself — the test +engineer owns that. -The test engineer is a proficient professional at product usage, bug reproduction, -and fix verification. Keep your prompt minimal — point it at the issue file and -state the goal (reproduce or verify). Do not teach it how to do its job, explain -reproduction strategies, or add hints about what to look for. It will figure that -out on its own. +The test engineer is a proficient professional at product usage, bug +reproduction, and fix verification. Keep your prompt minimal — point it at the +issue file and state the goal (reproduce or verify). Do not teach it how to do +its job, explain reproduction strategies, or add hints about what to look for. +It will figure that out on its own. -Wait for the test engineer to finish. Then **read `.qwen/issues/issue-.md`** -to get the reproduction report. If the status is `NOT_REPRODUCED`, say so and -stop. +Wait for the test engineer to finish. Then **read +`.qwen/issues/issue-.md`** to get the reproduction report. If the status +is `NOT_REPRODUCED`, say so and stop. ### 3. Locate and fix -Read the relevant code and make the fix. Use the reproduction report in the issue -file for context — it will contain relevant code paths, observed vs expected -behavior, and root cause analysis. +Read the relevant code and make the fix. Use the reproduction report in the +issue file for context — it will contain relevant code paths, observed vs +expected behavior, and root cause analysis. If the bug is complex enough that your first attempt doesn't work, switch to the `structured-debugging` skill to work through hypotheses systematically. @@ -67,9 +69,9 @@ If the bug is complex enough that your first attempt doesn't work, switch to the Build your changes (`npm run build && npm run bundle`), then spawn the `test-engineer` agent again and tell it to read `.qwen/issues/issue-.md` -and _verify_ the fix. It will re-run its reproduction steps using -`node dist/cli.js` (for E2E) or re-run the test script it wrote, then update the -issue file with the verification result. +and _verify_ the fix. It will re-run its reproduction steps using `node +dist/cli.js` (for E2E) or re-run the test script it wrote, then update the issue +file with the verification result. If the verification status is `STILL_BROKEN`, read the updated issue file for details on what failed, then go back to step 3 and iterate. Use the @@ -79,7 +81,7 @@ until verification returns `VERIFIED_FIXED`. ### 5. Tests Run the unit tests for any packages you modified. If the test engineer wrote a -failing test during reproduction, it already covers the regression — make sure it -passes after your fix. Otherwise, add a test (unit or integration) that covers -the failure scenario from the issue so a future regression gets caught +failing test during reproduction, it already covers the regression — make sure +it passes after your fix. Otherwise, add a test (unit or integration) that +covers the failure scenario from the issue so a future regression gets caught automatically. diff --git a/.qwen/commands/qc/code-review.md b/.qwen/commands/qc/code-review.md index 021a80d9f..6d7a0c6b6 100644 --- a/.qwen/commands/qc/code-review.md +++ b/.qwen/commands/qc/code-review.md @@ -4,14 +4,17 @@ 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 +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: diff --git a/.qwen/commands/qc/commit.md b/.qwen/commands/qc/commit.md index fab58da2e..bc86ae1e5 100644 --- a/.qwen/commands/qc/commit.md +++ b/.qwen/commands/qc/commit.md @@ -6,16 +6,17 @@ description: Commit staged changes with an AI-generated commit message and push ## Overview -Generate a clear, concise commit message based on staged changes, confirm with the user, then commit and push. +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? +- Are there any staged changes? +- Are there unstaged changes? +- What is the current branch? ### 2. Handle unstaged changes @@ -27,32 +28,34 @@ Generate a clear, concise commit message based on staged changes, confirm with t - 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 +- 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 ` +- 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 +- 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 a new branch, (2) Commit 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 +- 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:** @@ -75,5 +78,5 @@ This . ### 7. Commit and push - After user confirms: - - `git commit -m ""` - - `git push -u origin ` (use `-u` for new branches) +- `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 index 020ef00d0..497b3fa14 100644 --- a/.qwen/commands/qc/create-issue.md +++ b/.qwen/commands/qc/create-issue.md @@ -6,39 +6,46 @@ description: Draft and submit a GitHub issue based on a user-provided idea ## 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. +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}} +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 +1. **Understand the request** -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 +- Read the user's description carefully +- Determine whether this is a feature request or a bug report -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 +2. **Investigate the codebase** -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 +- 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 -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 +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 index a2dc7fd05..208193cce 100644 --- a/.qwen/commands/qc/create-pr.md +++ b/.qwen/commands/qc/create-pr.md @@ -10,32 +10,39 @@ 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 +1. **Review staged 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 +- Review all staged changes to understand what has been done +- Do not touch unstaged changes -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" - - Leave the "Screenshots / Video Demo" section empty for the author to fill in manually - - Add this line at the end of PR body: "🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)", with a line separator +2. **Prepare branch** -4. **Set up PR** - - Create PR title and body - - Submit PR with gh command - - **If a GitHub token is provided in the user's message**, use it by setting the `GH_TOKEN` environment variable: - ```bash - GH_TOKEN= gh pr create --title "..." --body "..." - ``` - - If no token is provided, use the default `gh` authentication +- 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" +- Leave the "Screenshots / Video Demo" section empty for the author to fill in + manually +- 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 +- **If a GitHub token is provided in the user's message**, use it by setting + the `GH_TOKEN` environment variable: + ```bash + GH_TOKEN= gh pr create --title "..." --body "..." + ``` +- If no token is provided, use the default `gh` authentication ## PR Template diff --git a/.qwen/skills/bugfix/SKILL.md b/.qwen/skills/bugfix/SKILL.md new file mode 100644 index 000000000..da9e4d5c9 --- /dev/null +++ b/.qwen/skills/bugfix/SKILL.md @@ -0,0 +1,106 @@ +--- +name: bugfix +description: Fix a bug from a GitHub issue, following the reproduce-first + workflow. Use when the user asks to fix a bug, investigate a GitHub issue, or + debug a user-reported problem. Takes a GitHub issue URL or number as input. +--- + +# Bugfix Workflow + +Follow this workflow for GitHub issue bugfixes. Do not skip reproduction; fixing +without first reproducing the bug tends to produce incomplete fixes and +regressions. + +## Input + +A GitHub issue URL or number. Slash-command arguments are appended to this skill +body by Qwen Code. + +## Artifact Path + +Use `.qwen/issues/` in this repo. In the steps below, `` means the +selected issue markdown file. + +## Step 1: Read The Issue + +Create the artifact directory if needed, then pipe the issue directly into a +markdown file using `gh`: + +```bash +mkdir -p .qwen/issues +gh issue view \ + --json number,title,body \ + -t '# Issue #{{.number}}: {{.title}} + +{{.body}} + +--- + +## Reproduction report + +_Pending - to be filled by the test engineer._ + +## Verification report + +_Pending - to be filled by the test engineer._ +' > .qwen/issues/issue-.md +``` + +## Step 2: Reproduce + +Spawn the `test-engineer` agent and point it at ``. State only the +goal: reproduce the bug. Keep the prompt minimal; the test engineer owns the +reproduction strategy. + +Wait for the test engineer to finish. Then read `` to get the +reproduction report. If the status is `NOT_REPRODUCED`, report that and stop. + +## Step 3: Fix + +Read the relevant code and make the fix. Use the reproduction report for +context; it should contain observed behavior, expected behavior, and useful code +paths. + +If the bug is complex enough that the first attempt does not work, use the +`structured-debugging` skill and work through hypotheses systematically. + +## Step 4: Verify + +Build and bundle your changes: + +```bash +npm run build && npm run bundle +``` + +Spawn the `test-engineer` agent again, pointing it at the same issue file. State +the goal: verify the fix using `node dist/cli.js`. + +If the verification status is `STILL_BROKEN`, read the updated issue file, go +back to Step 3, and iterate. Do not proceed until verification returns +`VERIFIED_FIXED`. + +## Step 5: Tests + +Run unit tests for any packages you modified. If the test engineer wrote a +failing test during reproduction, make sure it passes after the fix. Otherwise, +add focused regression coverage for the failure scenario. + +## Step 6: Code Review + +Skip this only for a plain one-line or trivial config fix. For anything else, +run `/review` with a review task listing all changed files. Triage each comment +with a verdict: + +- **Valid**: real bug or meaningful improvement. Fix it. +- **False positive**: reviewer missed context. Skip it. +- **Overthinking**: technically plausible but not worth the complexity. Skip + it. + +After fixing valid issues, re-run unit tests and a quick verification sanity +check. + +## Iteration Rules + +- If Step 4 fails, go back to Step 3, then re-run Step 4. +- If Step 6 finds valid issues, fix them, then re-run Step 4 as a sanity check. +- Do not loop more than 3 times between Steps 3-6 without asking the user. diff --git a/.qwen/skills/docs-audit-and-refresh/SKILL.md b/.qwen/skills/docs-audit-and-refresh/SKILL.md index f06161632..0e656ab5a 100644 --- a/.qwen/skills/docs-audit-and-refresh/SKILL.md +++ b/.qwen/skills/docs-audit-and-refresh/SKILL.md @@ -1,25 +1,36 @@ --- name: docs-audit-and-refresh -description: Audit the repository's docs/ content against the current codebase, find missing, incorrect, or stale documentation, and refresh the affected pages. Use when the user asks to review docs coverage, find outdated docs, compare docs with the current repo, or fix documentation drift across features, settings, tools, or integrations. +description: Audit the repository's docs/ content against the current codebase, + find missing, incorrect, or stale documentation, and refresh the affected + pages. Use when the user asks to review docs coverage, find outdated docs, + compare docs with the current repo, or fix documentation drift across + features, settings, tools, or integrations. --- # Docs Audit And Refresh ## Overview -Audit `docs/` from the repository outward: inspect the current implementation, identify documentation gaps or inaccuracies, and update the relevant pages. Keep the work inside `docs/` and treat code, tests, and current configuration surfaces as the authoritative source. +Audit `docs/` from the repository outward: inspect the current implementation, +identify documentation gaps or inaccuracies, and update the relevant pages. Keep +the work inside `docs/` and treat code, tests, and current configuration +surfaces as the authoritative source. -Read [references/audit-checklist.md](references/audit-checklist.md) before a broad audit so the scan stays focused on high-signal areas. +Read [references/audit-checklist.md](references/audit-checklist.md) before a +broad audit so the scan stays focused on high-signal areas. ## Workflow ### 1. Build a current-state inventory -Inspect the repository areas that define user-facing or developer-facing behavior. +Inspect the repository areas that define user-facing or developer-facing +behavior. - Read the relevant code, tests, schemas, and package surfaces. -- Focus on shipped behavior, stable configuration, exposed commands, integrations, and developer workflows. -- Use the existing docs tree as a map of intended coverage, not as proof that coverage is complete. +- Focus on shipped behavior, stable configuration, exposed commands, + integrations, and developer workflows. +- Use the existing docs tree as a map of intended coverage, not as proof that + coverage is complete. ### 2. Compare implementation against `docs/` @@ -29,16 +40,17 @@ Look for three classes of issues: - Incorrect documentation that contradicts the current codebase - Stale documentation that uses old names, defaults, paths, or examples -Prefer proving a gap with repository evidence before editing. Use current code and tests instead of intuition. +Prefer proving a gap with repository evidence before editing. Use current code +and tests instead of intuition. ### 3. Prioritize by reader impact Fix the highest-cost issues first: -1. Broken onboarding, setup, auth, installation, or command flows -2. Wrong settings, defaults, paths, or feature behavior -3. Entirely missing documentation for a real surface area -4. Lower-impact clarity or organization improvements +1. Broken onboarding, setup, auth, installation, or command flows +2. Wrong settings, defaults, paths, or feature behavior +3. Entirely missing documentation for a real surface area +4. Lower-impact clarity or organization improvements ### 4. Refresh the docs @@ -64,8 +76,10 @@ Before finishing: - Favor breadth-first discovery, then depth on confirmed gaps. - Do not rewrite large areas without evidence that they are wrong or missing. - Keep README files out of scope for edits; limit changes to `docs/`. -- Call out residual gaps if the audit finds issues that are too large to solve in one pass. +- Call out residual gaps if the audit finds issues that are too large to solve + in one pass. ## Deliverable -Produce a focused docs refresh that makes the current repository more accurate and complete. Summarize the audited surfaces and the concrete pages updated. +Produce a focused docs refresh that makes the current repository more accurate +and complete. Summarize the audited surfaces and the concrete pages updated. diff --git a/.qwen/skills/docs-audit-and-refresh/references/audit-checklist.md b/.qwen/skills/docs-audit-and-refresh/references/audit-checklist.md index 54c0fb00f..6798e357a 100644 --- a/.qwen/skills/docs-audit-and-refresh/references/audit-checklist.md +++ b/.qwen/skills/docs-audit-and-refresh/references/audit-checklist.md @@ -1,26 +1,29 @@ # Audit Checklist -Use this checklist to keep repository-wide documentation audits focused and repeatable. +Use this checklist to keep repository-wide documentation audits focused and +repeatable. ## High-signal repository surfaces -- `packages/cli/**` - Inspect commands, flows, prompts, flags, and CLI-facing behavior. -- `packages/core/**` - Inspect shared behavior, settings, tools, provider integration, and feature semantics. -- `packages/sdk-typescript/**` and `packages/sdk-java/**` - Inspect SDK setup, usage, and examples that may affect developer docs. -- `packages/vscode-ide-companion/**`, `packages/zed-extension/**`, and related integration packages - Inspect IDE and extension behavior that should be reflected in user docs. -- `docs/**/_meta.ts` - Inspect navigation completeness after creating or moving pages. +- `packages/cli/**` Inspect commands, flows, prompts, flags, and CLI-facing + behavior. +- `packages/core/**` Inspect shared behavior, settings, tools, provider + integration, and feature semantics. +- `packages/sdk-typescript/**` and `packages/sdk-java/**` Inspect SDK setup, + usage, and examples that may affect developer docs. +- `packages/vscode-ide-companion/**`, `packages/zed-extension/**`, and related + integration packages Inspect IDE and extension behavior that should be + reflected in user docs. +- `docs/**/_meta.ts` Inspect navigation completeness after creating or moving + pages. ## Gap detection prompts Ask these questions while comparing the repo to `docs/`: - Does a visible feature exist in code but have no page or section in `docs/`? -- Does a docs page mention a command, setting, provider, or path that no longer exists? +- Does a docs page mention a command, setting, provider, or path that no longer + exists? - Do examples still match the current repository layout and command syntax? - Is a page present but hidden or missing from `_meta.ts`? - Do multiple pages describe the same feature inconsistently? @@ -38,4 +41,5 @@ Ask these questions while comparing the repo to `docs/`: - Prefer a small number of precise edits over a speculative docs rewrite. - Leave a clear summary of what was missing, wrong, or stale. -- If the audit uncovers a larger docs reorganization, fix the highest-impact inaccuracies first and note the remaining work. +- If the audit uncovers a larger docs reorganization, fix the highest-impact + inaccuracies first and note the remaining work. diff --git a/.qwen/skills/docs-update-from-diff/SKILL.md b/.qwen/skills/docs-update-from-diff/SKILL.md index 1f7eb722c..c9f62fae7 100644 --- a/.qwen/skills/docs-update-from-diff/SKILL.md +++ b/.qwen/skills/docs-update-from-diff/SKILL.md @@ -1,15 +1,22 @@ --- name: docs-update-from-diff -description: Review local code changes with git diff and update the official docs under docs/ to match. Use when the user asks to document current uncommitted work, sync docs with local changes, update docs after a feature or refactor, or when phrases like "git diff", "local changes", "update docs", or "official docs" appear. +description: Review local code changes with git diff and update the official + docs under docs/ to match. Use when the user asks to document current + uncommitted work, sync docs with local changes, update docs after a feature or + refactor, or when phrases like "git diff", "local changes", "update docs", or + "official docs" appear. --- # Docs Update From Diff ## Overview -Inspect local diffs, derive the documentation impact, and update only the repository's `docs/` pages. Treat the current code as the source of truth and keep changes scoped, specific, and navigable. +Inspect local diffs, derive the documentation impact, and update only the +repository's `docs/` pages. Treat the current code as the source of truth and +keep changes scoped, specific, and navigable. -Read [references/docs-surface.md](references/docs-surface.md) before editing if the affected feature does not map cleanly to an existing docs section. +Read [references/docs-surface.md](references/docs-surface.md) before editing if +the affected feature does not map cleanly to an existing docs section. ## Workflow @@ -17,30 +24,38 @@ Read [references/docs-surface.md](references/docs-surface.md) before editing if Start from local Git state, not from assumptions. -- Inspect `git status --short`, `git diff --stat`, and targeted `git diff` output. -- Focus on non-doc changes first so the documentation delta is grounded in code. -- Ignore `README.md` and other non-`docs/` content unless they help confirm intent. +- Inspect `git status --short`, `git diff --stat`, and targeted `git diff` + output. +- Focus on non-doc changes first so the documentation delta is grounded in + code. +- Ignore `README.md` and other non-`docs/` content unless they help confirm + intent. ### 2. Derive the docs impact -For every changed behavior, extract the user-facing or developer-facing facts that documentation must reflect. +For every changed behavior, extract the user-facing or developer-facing facts +that documentation must reflect. - New command, flag, config key, default, workflow, or limitation - Renamed behavior or removed behavior - Changed examples, paths, or setup steps - New feature that belongs in an existing page but is not mentioned yet -Prefer updating an existing page over creating a new page. Create a new page only when the feature introduces a stable topic that would make an existing page harder to follow. +Prefer updating an existing page over creating a new page. Create a new page +only when the feature introduces a stable topic that would make an existing page +harder to follow. ### 3. Find the right docs location Map each change to the smallest correct documentation surface: - End-user behavior: `docs/users/**` -- Developer internals, SDKs, contributor workflow, tooling: `docs/developers/**` +- Developer internals, SDKs, contributor workflow, tooling: + `docs/developers/**` - Shared landing or navigation changes: root `docs/**` and `_meta.ts` -If you add a new page, update the nearest `_meta.ts` in the same docs section so the page is discoverable. +If you add a new page, update the nearest `_meta.ts` in the same docs section so +the page is discoverable. ### 4. Write the update @@ -63,11 +78,17 @@ Verify that the updated docs cover the actual delta: ## Practical heuristics -- If a change affects commands, also check quickstart, workflows, and feature pages for drift. -- If a change affects configuration, also check `docs/users/configuration/settings.md`, feature pages, and auth/provider docs. -- If a change affects tools or agent behavior, check both `docs/users/features/**` and `docs/developers/tools/**` when relevant. -- If tests reveal expected behavior more clearly than implementation code, use tests to confirm wording. +- If a change affects commands, also check quickstart, workflows, and feature + pages for drift. +- If a change affects configuration, also check + `docs/users/configuration/settings.md`, feature pages, and auth/provider docs. +- If a change affects tools or agent behavior, check both + `docs/users/features/**` and `docs/developers/tools/**` when relevant. +- If tests reveal expected behavior more clearly than implementation code, use + tests to confirm wording. ## Deliverable -Produce the docs edits under `docs/` that make the current local changes understandable to a reader who has not seen the diff. Keep the final summary short and identify which pages were updated. +Produce the docs edits under `docs/` that make the current local changes +understandable to a reader who has not seen the diff. Keep the final summary +short and identify which pages were updated. diff --git a/.qwen/skills/docs-update-from-diff/references/docs-surface.md b/.qwen/skills/docs-update-from-diff/references/docs-surface.md index a55f0a9b4..cad04f98c 100644 --- a/.qwen/skills/docs-update-from-diff/references/docs-surface.md +++ b/.qwen/skills/docs-update-from-diff/references/docs-surface.md @@ -4,36 +4,39 @@ Use this file to choose the correct destination page under `docs/`. ## Primary sections -- `docs/users/overview.md`, `quickstart.md`, `common-workflow.md` - Good for entry points, first-run guidance, and broad user workflows. -- `docs/users/features/*.md` - Good for user-visible features such as skills, MCP, sandbox, sub-agents, commands, checkpointing, and approval modes. -- `docs/users/configuration/*.md` - Good for settings, auth, model providers, themes, trusted folders, `.qwen` files, and similar configuration topics. -- `docs/users/integration-*.md` and `docs/users/ide-integration/*.md` - Good for IDEs, GitHub Actions, and editor companion behavior. -- `docs/users/extension/*.md` - Good for extension authoring and extension usage. -- `docs/developers/*.md` - Good for architecture, contributing workflow, roadmaps, and SDK overviews. -- `docs/developers/tools/*.md` - Good for tool behavior, tool contracts, and implementation-facing explanations. -- `docs/developers/development/*.md` - Good for contributor setup, deployment, tests, telemetry, and automation details. +- `docs/users/overview.md`, `quickstart.md`, `common-workflow.md` Good for + entry points, first-run guidance, and broad user workflows. +- `docs/users/features/*.md` Good for user-visible features such as skills, + MCP, sandbox, sub-agents, commands, checkpointing, and approval modes. +- `docs/users/configuration/*.md` Good for settings, auth, model providers, + themes, trusted folders, `.qwen` files, and similar configuration topics. +- `docs/users/integration-*.md` and `docs/users/ide-integration/*.md` Good for + IDEs, GitHub Actions, and editor companion behavior. +- `docs/users/extension/*.md` Good for extension authoring and extension usage. +- `docs/developers/*.md` Good for architecture, contributing workflow, + roadmaps, and SDK overviews. +- `docs/developers/tools/*.md` Good for tool behavior, tool contracts, and + implementation-facing explanations. +- `docs/developers/development/*.md` Good for contributor setup, deployment, + tests, telemetry, and automation details. ## Navigation rules - Root navigation lives in `docs/_meta.ts`. - Section navigation lives in the nearest `_meta.ts`, for example: - - `docs/users/_meta.ts` - - `docs/users/features/_meta.ts` - - `docs/developers/_meta.ts` - - `docs/developers/tools/_meta.ts` -- If you create a page and do not add it to the right `_meta.ts`, the docs will be incomplete even if the markdown exists. +- `docs/users/_meta.ts` +- `docs/users/features/_meta.ts` +- `docs/developers/_meta.ts` +- `docs/developers/tools/_meta.ts` +- If you create a page and do not add it to the right `_meta.ts`, the docs will + be incomplete even if the markdown exists. ## Placement heuristics - Put the change where a reader would naturally look first. -- Update multiple pages when a single feature appears in setup, reference, and workflow docs. -- Prefer adjusting a nearby existing page instead of creating a top-level page for a small delta. -- Avoid duplicating long explanations across pages; add one source page and update nearby pages with short pointers if needed. +- Update multiple pages when a single feature appears in setup, reference, and + workflow docs. +- Prefer adjusting a nearby existing page instead of creating a top-level page + for a small delta. +- Avoid duplicating long explanations across pages; add one source page and + update nearby pages with short pointers if needed. diff --git a/.qwen/skills/e2e-testing/SKILL.md b/.qwen/skills/e2e-testing/SKILL.md index 105248d26..d53c8572c 100644 --- a/.qwen/skills/e2e-testing/SKILL.md +++ b/.qwen/skills/e2e-testing/SKILL.md @@ -1,25 +1,31 @@ --- name: e2e-testing -description: Guide for running end-to-end tests of the Qwen Code CLI, including headless mode, MCP server testing, and API traffic inspection. Use this skill whenever you need to verify CLI behavior with real model calls, reproduce user-reported bugs end-to-end, test MCP tool integrations, or inspect raw API request/response payloads. Trigger on mentions of E2E testing, headless testing, MCP tool testing, or reproducing issues. +description: Guide for running end-to-end tests of the Qwen Code CLI, including + headless mode, MCP server testing, and API traffic inspection. Use this skill + whenever you need to verify CLI behavior with real model calls, reproduce + user-reported bugs end-to-end, test MCP tool integrations, or inspect raw API + request/response payloads. Trigger on mentions of E2E testing, headless + testing, MCP tool testing, or reproducing issues. --- # E2E Testing Guide -How to run the Qwen Code CLI end-to-end — from building the bundle to inspecting -raw API traffic. Use when unit tests aren't enough and you need to verify behavior -through the full pipeline (model API → tool validation → tool execution). +How to run the Qwen Code CLI end-to-end, from building the bundle to inspecting +raw API traffic. Use when unit tests are not enough and you need to verify +behavior through the full pipeline (model API → tool validation → tool +execution). ## Which binary to use -- **Reproducing bugs**: use the globally installed `qwen` command — this matches - what the user ran when they filed the issue. -- **Verifying fixes**: build first (`npm run build && npm run bundle`), then run - `node dist/cli.js` — this tests your local changes. +- **Reproducing bugs**: use the globally installed `qwen` command — this + matches what the user ran when they filed the issue. +- **Verifying fixes**: build first (`npm run build && npm run bundle`), then + run `node dist/cli.js` — this tests your local changes. ## Headless Mode -Run the CLI non-interactively with JSON output (`` = `qwen` or -`node dist/cli.js` per above): +Run the CLI non-interactively with JSON output (`` = `qwen` or `node +dist/cli.js` per above): ```bash "your prompt here" \ @@ -31,12 +37,15 @@ Run the CLI non-interactively with JSON output (`` = `qwen` or The JSON output is a stream of objects. Key types: - `type: "system"` — init: `tools`, `mcp_servers`, `model`, `permission_mode` -- `type: "assistant"` — model output: `content[].type` is `text`, `tool_use`, or `thinking` -- `type: "user"` — tool results: `content[].type` is `tool_result` with `is_error` +- `type: "assistant"` — model output: `content[].type` is `text`, `tool_use`, + or `thinking` +- `type: "user"` — tool results: `content[].type` is `tool_result` with + `is_error` - `type: "result"` — final output with `result` text and `usage` stats Pipe through `jq` to filter the verbose stream, e.g. extract tool-result errors: -`... 2>/dev/null | jq 'select(.type=="user") | .message.content[] | select(.is_error)'` +`... 2>/dev/null | jq 'select(.type=="user") | .message.content[] | +select(.is_error)'` ## Inspecting Raw API Traffic @@ -59,7 +68,11 @@ The bulk is in `request.messages` (conversation history). Trimmed structure: "request": { "model": "coder-model", "messages": [ - { "role": "system|user|assistant", "content": "...", "tool_calls?": [...] } + { + "role": "system|user|assistant", + "content": "...", + "tool_calls?": [] + } ], "tools": [ { @@ -97,7 +110,8 @@ The bulk is in `request.messages` (conversation history). Trimmed structure: ## Interactive Mode (tmux) Use when you need to verify TUI rendering, test keyboard interactions, or see -what the user sees. Headless mode is simpler when you only need structured output. +what the user sees. Headless mode is simpler when you only need structured +output. ### Launching @@ -154,8 +168,8 @@ tmux kill-session -t test ## MCP Server Testing For testing MCP tool behavior end-to-end, read `references/mcp-testing.md`. It -covers the setup gotchas (config location, git repo requirement) and includes -a reusable zero-dependency test server template in `scripts/mcp-test-server.js`. +covers the setup gotchas (config location, git repo requirement) and includes a +reusable zero-dependency test server template in `scripts/mcp-test-server.js`. ## Token Usage Stats diff --git a/.qwen/skills/e2e-testing/references/mcp-testing.md b/.qwen/skills/e2e-testing/references/mcp-testing.md index 81dd655e2..9b8c310ba 100644 --- a/.qwen/skills/e2e-testing/references/mcp-testing.md +++ b/.qwen/skills/e2e-testing/references/mcp-testing.md @@ -10,7 +10,7 @@ the **only** location that works for E2E testing. Common mistakes that waste time: - `.mcp.json` — Claude Code convention, not Qwen Code -- `settings.local.json` — the JSON schema validation rejects `mcpServers` here +- `settings.local.json` — schema validation rejects `mcpServers` here - `--mcp-config` CLI flag — does not exist ## Setup @@ -42,8 +42,8 @@ cd /tmp/test-dir && "prompt" \ ## Writing Test Servers -Use `scripts/mcp-test-server.js` as a template. It's a zero-dependency -JSON-RPC server over stdin/stdout — no npm install needed. +Use `scripts/mcp-test-server.js` as a template. It's a zero-dependency JSON-RPC +server over stdin/stdout — no npm install needed. To create a server with custom tools, copy the template and edit the `TOOL_DEFINITIONS` array and the `handleToolCall` function. Each tool definition @@ -55,7 +55,16 @@ Test the server without the CLI by piping JSON-RPC directly: ```bash node /tmp/my-mcp-server.js << 'EOF' -{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}} +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "test", "version": "1.0" } + } +} {"jsonrpc":"2.0","method":"notifications/initialized"} {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} EOF diff --git a/.qwen/skills/feat-dev/SKILL.md b/.qwen/skills/feat-dev/SKILL.md new file mode 100644 index 000000000..d5b31da32 --- /dev/null +++ b/.qwen/skills/feat-dev/SKILL.md @@ -0,0 +1,141 @@ +--- +name: feat-dev +description: End-to-end workflow for implementing a non-trivial qwen-code + feature. Covers requirements investigation, design, E2E test planning, + baseline dry-run, implementation, verification, code review, and iteration. +--- + +# Feature Development Workflow + +Use this workflow when implementing a feature in qwen-code that needs design, +behavioral validation, or coordinated changes across multiple files. Each phase +produces a concrete artifact. Do not combine phases; the output of each phase +feeds the next. + +## Artifact Paths + +Use `.qwen/` paths for planning artifacts: + +- `.qwen/design/.md` +- `.qwen/e2e-tests/.md` + +## Phase 1: Investigate + +Understand the requested behavior and the current qwen-code implementation. + +Use a code exploration agent when available. Ask it to inspect the relevant +qwen-code areas for: + +- Existing feature definitions: tools, parameters, schemas, commands, UI, or + config. +- Runtime wiring: spawning, lifecycle, state, permissions, hooks, and cleanup. +- Edge cases and error handling. +- Integration points and limitations. + +In parallel, inspect docs, issues, tests, and nearby implementations that define +or constrain the expected behavior. If no exploration agent is available, do the +same investigation locally. + +Output: mental model of current behavior, desired behavior, constraints, and key +file paths with line numbers. + +## Phase 2: Design Doc + +Write a design doc covering: + +- Problem statement and current state, including the behavior gap. +- Proposed changes by layer or component. +- Key design decisions and rationale. +- Files affected. +- Scope boundaries. +- Open questions. + +Use prose, tables, and bullets. Avoid code snippets unless essential for a key +data structure. JSON config examples are acceptable. + +Output: design doc on disk. + +## Phase 3: Test Plan + +Use the `e2e-testing` skill to choose test modes. Then write an E2E test plan +covering: + +- Test groups by capability: parameter acceptance, core behavior, error + handling, cleanup, and regressions. +- Exact commands and expected behavior before and after implementation. +- Unique tmux session names and temp dirs for independent groups. +- Which groups can be run in parallel by separate `test-engineer` agents. + +Output: test plan on disk. + +## Phase 4: Dry-Run + +Validate the test plan against the current baseline using the globally installed +`qwen` CLI, not the local build. + +Spawn `test-engineer` agents for independent test groups when the runtime +supports it. The feature is not implemented yet, so tests should either fail or +show the gap. Iterate the test plan if the dry-run reveals broken commands, +wrong filters, or false positives. + +Output: confirmed-working test plan with accurate pre-implementation baseline. + +## Phase 5: Implement + +Read the relevant source files before editing. Implement the changes described +in the design doc and follow project conventions: + +- ESM and strict TypeScript. +- Prettier formatting. +- Collocated tests next to source. +- No speculative abstractions beyond the design. + +After implementation: + +```bash +npm run build +npm run typecheck +npm run bundle +``` + +Also run focused unit tests for changed files from the relevant package +directory. + +Output: local implementation that builds and passes focused tests. + +## Phase 6: Verify + +Run the full E2E test plan against the local build with `node dist/cli.js`. +Spawn independent `test-engineer` agents when useful and available. + +If tests fail, diagnose, fix, rebuild, re-bundle, and re-test until all groups +pass. + +Output: E2E results appended to the test plan. + +## Phase 7: Code Review + +Run `/review` with a review task listing all changed files. Triage each comment +before acting: + +- **Valid**: real bug or meaningful improvement. Fix it. +- **False positive**: reviewer missed context. Skip it. +- **Overthinking**: technically plausible but not worth the complexity. Skip + it. + +After fixes, re-run unit tests and a quick E2E sanity check. + +Output: clean implementation with valid review findings addressed. + +## Phase 8: Wrap Up + +Skip unless the user asks. Create the branch, commit with Conventional Commits, +push, and create a draft PR using the project PR template. Post E2E results as a +separate PR comment when applicable. + +## Iteration Rules + +- If Phase 6 fails, return to Phase 5 and then re-run Phase 6. +- If Phase 7 finds valid issues, fix them and run a quick Phase 6 sanity check. +- Do not loop more than 3 times between Phases 5-7 without asking the user. +- If the test plan is inaccurate, update it and document why. diff --git a/.qwen/skills/qwen-code-claw/SKILL.md b/.qwen/skills/qwen-code-claw/SKILL.md index 5571282b0..3d12203e3 100644 --- a/.qwen/skills/qwen-code-claw/SKILL.md +++ b/.qwen/skills/qwen-code-claw/SKILL.md @@ -1,6 +1,7 @@ --- name: qwen-code-claw -description: Use Qwen Code as a Code Agent for code understanding, project generation, features, bug fixes, refactoring, and various programming tasks +description: Use Qwen Code as a Code Agent for code understanding, project + generation, features, bug fixes, refactoring, and various programming tasks --- # Qwen Code Claw @@ -13,7 +14,8 @@ Use this skill when you need to: - Generate new projects or add new features - Review pull requests in the codebase - Fix bugs or refactor existing code -- Execute various programming tasks such as code review, testing, documentation generation, etc. +- Execute various programming tasks such as code review, testing, documentation + generation, etc. - Collaborate with other tools and agents to complete complex development tasks ## Install @@ -32,7 +34,8 @@ Check if authentication is already configured: qwen auth status ``` -If authentication exists, skip this section. If not authenticated, check if the `BAILIAN_CODING_PLAN_API_KEY` environment variable exists: +If authentication exists, skip this section. If not authenticated, check if the +`BAILIAN_CODING_PLAN_API_KEY` environment variable exists: ```bash echo $BAILIAN_CODING_PLAN_API_KEY @@ -44,7 +47,8 @@ echo $BAILIAN_CODING_PLAN_API_KEY qwen auth coding-plan --region china --key $BAILIAN_CODING_PLAN_API_KEY ``` -**If the environment variable does not exist**, interrupt and prompt the user to authenticate via `qwen-oauth` or `coding-plan`: +**If the environment variable does not exist**, interrupt and prompt the user to +authenticate via `qwen-oauth` or `coding-plan`: ```bash qwen auth @@ -54,35 +58,37 @@ Or configure custom API after launching Qwen Code via `/auth`. ## ACPX Integration -You should always use `acpx` to run Qwen Code over the Agent Client Protocol (ACP) instead of scraping PTY sessions. +You should always use `acpx` to run Qwen Code over the Agent Client Protocol +(ACP) instead of scraping PTY sessions. To use acpx: -1. **Install acpx globally** (recommended) or use npx: +1. **Install acpx globally** (recommended) or use npx: - ```bash - npm install -g acpx@latest - ``` +```bash +npm install -g acpx@latest +``` - Or without installing: +Or without installing: - ```bash - npx acpx@latest - ``` +```bash +npx acpx@latest +``` -2. **Install the acpx skill** for full reference: +2. **Install the acpx skill** for full reference: - ```bash - npx acpx@latest --skill install acpx - ``` +```bash +npx acpx@latest --skill install acpx +``` -3. **Read the acpx skill reference** for every command, flag, and workflow pattern: - See the [ACPX Reference](#acpx-reference) section below for details +3. **Read the acpx skill reference** for every command, flag, and workflow + pattern: See the [ACPX Reference](#acpx-reference) section below for details -4. **For complete CLI reference with all options and examples**: - https://raw.githubusercontent.com/openclaw/acpx/main/docs/CLI.md +4. **For complete CLI reference with all options and examples**: + https://raw.githubusercontent.com/openclaw/acpx/main/docs/CLI.md -From now on, when you need to delegate work to Qwen Code, use `acpx` instead of spawning raw terminal sessions. For example: +From now on, when you need to delegate work to Qwen Code, use `acpx` instead of +spawning raw terminal sessions. For example: ```bash npx acpx@latest qwen "fix the failing tests" @@ -90,7 +96,8 @@ npx acpx@latest qwen "refactor the auth module" npx acpx@latest qwen exec "one-shot: summarize this repo" ``` -> **Tip:** You should typically use the `--approve-all` flag to prevent task interruptions. +> **Tip:** You should typically use the `--approve-all` flag to prevent task +> interruptions. ## Practical Workflows @@ -140,15 +147,16 @@ acpx --cwd ~/repos/my-project --approve-all qwen -s pr-123 \ - `--approve-reads` (default): Auto-approve reads/searches, prompt for writes - `--deny-all`: Deny all permission requests -If every permission request is denied/cancelled and none are approved, `acpx` exits with permission denied. +If every permission request is denied/cancelled and none are approved, `acpx` +exits with permission denied. ## Best Practices -1. Use **named sessions** for organizing different types of development tasks -2. Use `--no-wait` for long-running tasks to avoid blocking -3. Use `--approve-all` for non-interactive batch operations -4. Use `--format json` for automation and script integration -5. Use `--cwd` to manage context across multiple projects +1. Use **named sessions** for organizing different types of development tasks +2. Use `--no-wait` for long-running tasks to avoid blocking +3. Use `--approve-all` for non-interactive batch operations +4. Use `--format json` for automation and script integration +5. Use `--cwd` to manage context across multiple projects ## QwenCode Reference @@ -163,11 +171,13 @@ If every permission request is denied/cancelled and none are approved, `acpx` ex | `/auth` | Configure authentication | | `/exit` | Exit Qwen Code | -Full reference: https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/features/commands.md +Full reference: `docs/users/features/commands.md`. ### Configuration -Config files (highest priority first): CLI args > env vars > system > project (`.qwen/settings.json`) > user (`~/.qwen/settings.json`) > defaults. Format: JSONC with env var interpolation. +Config files (highest priority first): CLI args > env vars > system > project +(`.qwen/settings.json`) > user (`~/.qwen/settings.json`) > defaults. Format: +JSONC with env var interpolation. Key settings: @@ -178,30 +188,36 @@ Key settings: | `permissions.allow/ask/deny` | Tool permission rules | | `mcpServers.*` | MCP server configurations | -Full reference: https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/configuration/settings.md +Full reference: `docs/users/configuration/settings.md`. ### Authentication -Supports Alibaba Cloud Coding Plan, OpenAI-compatible API keys, and Qwen OAuth (free tier discontinued 2026-04-15). +Supports Alibaba Cloud Coding Plan, OpenAI-compatible API keys, and Qwen OAuth +(free tier discontinued 2026-04-15). -Full reference: https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/configuration/auth.md +Full reference: `docs/users/configuration/auth.md`. ### Model Providers -Configure custom model providers via `modelProviders` in settings or environment variables (`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`). +Configure custom model providers via `modelProviders` in settings or environment +variables (`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`). -Full reference: https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/configuration/model-providers.md +Full reference: `docs/users/configuration/model-providers.md`. ### Key Features -| Feature | Description | Docs | -| ------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| Approval Mode | Control tool execution permissions | https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/features/approval-mode.md | -| MCP | Model Context Protocol server integration | https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/features/mcp.md | -| Skills | Reusable skill system via `/skill` | https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/features/skills.md | -| Sub-agents | Delegate tasks to specialized agents | https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/features/sub-agents.md | -| Sandbox | Secure code execution environment | https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/features/sandbox.md | -| Headless | Non-interactive / CI mode | https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/docs/users/features/headless.md | +- Approval Mode: control tool execution permissions. + See `docs/users/features/approval-mode.md`. +- MCP: Model Context Protocol server integration. + See `docs/users/features/mcp.md`. +- Skills: reusable skill system via `/skill`. + See `docs/users/features/skills.md`. +- Sub-agents: delegate tasks to specialized agents. + See `docs/users/features/sub-agents.md`. +- Sandbox: secure code execution environment. + See `docs/users/features/sandbox.md`. +- Headless: non-interactive or CI mode. + See `docs/users/features/headless.md`. ## ACPX Reference @@ -226,7 +242,10 @@ acpx [global options] cancel [-s ] acpx [global options] set-mode [-s ] acpx [global options] set [-s ] acpx [global options] status [-s ] -acpx [global options] sessions [list | new [--name ] | close [name] | show [name] | history [name] [--limit ]] +acpx [global options] sessions [ + list | new [--name ] | close [name] | show [name] | + history [name] [--limit ] +] acpx [global options] config [show | init] # With explicit agent @@ -235,20 +254,19 @@ acpx [global options] prompt [options] [prompt text...] acpx [global options] exec [options] [prompt text...] ``` -> **Note:** If prompt text is omitted and stdin is piped, `acpx` reads prompt from stdin. +> **Note:** If prompt text is omitted and stdin is piped, `acpx` reads prompt +> from stdin. ### Global Options -| Option | Description | -| --------------------- | ------------------------------------------------------------ | -| `--agent ` | Raw ACP agent command (fallback mechanism) | -| `--cwd ` | Session working directory | -| `--approve-all` | Auto-approve all requests | -| `--approve-reads` | Auto-approve reads/searches, prompt for writes (default) | -| `--deny-all` | Deny all requests | -| `--format ` | Output format: `text`, `json`, `quiet` | -| `--timeout ` | Maximum wait time (positive integer) | -| `--ttl ` | Idle TTL for queue owners (default: `300`, `0` disables TTL) | -| `--verbose` | Verbose ACP/debug logs to stderr | +- `--agent `: raw ACP agent command fallback. +- `--cwd `: session working directory. +- `--approve-all`: auto-approve all requests. +- `--approve-reads`: auto-approve reads/searches, prompt for writes. +- `--deny-all`: deny all requests. +- `--format `: output format, one of `text`, `json`, or `quiet`. +- `--timeout `: maximum wait time. +- `--ttl `: idle TTL for queue owners. +- `--verbose`: verbose ACP/debug logs to stderr. Flags are mutually exclusive where applicable. diff --git a/.qwen/skills/structured-debugging/SKILL.md b/.qwen/skills/structured-debugging/SKILL.md index da19174cf..eb5dd196e 100644 --- a/.qwen/skills/structured-debugging/SKILL.md +++ b/.qwen/skills/structured-debugging/SKILL.md @@ -1,43 +1,42 @@ --- name: structured-debugging -description: > - Hypothesis-driven debugging methodology for hard bugs. Use this skill whenever - you're investigating non-trivial bugs, unexpected behavior, flaky tests, or - tracing issues through complex systems. Activate proactively when debugging - requires more than a quick glance — especially when the first attempt at a fix - didn't work, when behavior seems "impossible", or when you're tempted to blame - an external system (model, API, library) without evidence. +description: Hypothesis-driven debugging methodology for hard bugs. Use this + skill whenever you're investigating non-trivial bugs, unexpected behavior, + flaky tests, or tracing issues through complex systems. Activate proactively + when debugging requires more than a quick glance — especially when the first + attempt at a fix didn't work, when behavior seems "impossible", or when you're + tempted to blame an external system (model, API, library) without evidence. --- # Structured Debugging -When debugging hard issues, the natural instinct is to form a theory and immediately -apply a fix. This fails more often than it works. The fix addresses the wrong cause, -adds complexity, creates false confidence, and obscures the real issue. Worse, after -several failed attempts you lose track of what's been tried and start guessing randomly. +When debugging hard issues, the natural instinct is to form a theory and +immediately apply a fix. This fails more often than it works. The fix addresses +the wrong cause, adds complexity, creates false confidence, and obscures the +real issue. Worse, after several failed attempts you lose track of what's been +tried and start guessing randomly. -This methodology replaces guessing with a disciplined cycle that converges on the -root cause. Each iteration narrows the search space. It's slower per attempt but -dramatically faster overall because you stop wasting runs on wrong theories. +This methodology replaces guessing with a disciplined cycle that converges on +the root cause. Each iteration narrows the search space. It's slower per attempt +but dramatically faster overall because you stop wasting runs on wrong theories. ## The Cycle ### 1. Hypothesize -Before touching code, write down what you think is happening and why. Be specific -about the expected state at each step in the execution path. +Before touching code, write down what you think is happening and why. Be +specific about the expected state at each step in the execution path. -Bad: "Something is wrong with the wait loop." -Good: "The leader hangs because `hasActiveTeammates()` returns true after all agents -have reported completed, likely because terminal status isn't being set on the agent -object after the backend process exits." +Bad: "Something is wrong with the wait loop." Good: "The leader hangs because +`hasActiveTeammates()` returns true after all agents have reported completed, +likely because terminal status isn't being set on the agent object after the +backend process exits." -For bugs you expect to take more than one round, create a side note file -for the investigation in whichever location the project uses for such -notes. +For bugs you expect to take more than one round, create a side note file for the +investigation in whichever location the project uses for such notes. -Write your hypothesis there. This file persists across conversation turns and even -across sessions — it's your investigation journal. +Write your hypothesis there. This file persists across conversation turns and +even across sessions — it's your investigation journal. ### 2. Design Instrumentation @@ -47,19 +46,18 @@ confirm or reject your hypothesis. Think about what data you need to see. Don't scatter `console.log` everywhere. Identify the 2-3 places where your hypothesis makes a testable prediction, and instrument those. -Prefer logging _values_ (return codes, payload contents, stream types, -message bodies, env state) over _presence checks_ ("was this function -called?", "was this branch taken?"). Code-path traces tell you what ran; -data traces tell you what it ran on. Most non-trivial bugs are correct -code processing wrong data. +Prefer logging _values_ (return codes, payload contents, stream types, message +bodies, env state) over _presence checks_ ("was this function called?", "was +this branch taken?"). Code-path traces tell you what ran; data traces tell you +what it ran on. Most non-trivial bugs are correct code processing wrong data. -Ask yourself: "If my hypothesis is correct, what will I see at point X? -If it's wrong, what will I see instead?" +Ask yourself: "If my hypothesis is correct, what will I see at point X? If it's +wrong, what will I see instead?" ### 3. Verify Data Collection -Before running, confirm that your instrumentation output will actually be captured -and accessible. +Before running, confirm that your instrumentation output will actually be +captured and accessible. Common traps: @@ -73,10 +71,11 @@ A test run that produces no data is wasted. ### 4. Run and Observe -Execute the test. Read the actual output — every line of it. Don't assume what it says. +Execute the test. Read the actual output — every line of it. Don't assume what +it says. -When the data contradicts your hypothesis, believe the data. Don't rationalize it -away. The whole point of this step is to let reality override your theory. +When the data contradicts your hypothesis, believe the data. Don't rationalize +it away. The whole point of this step is to let reality override your theory. ### 5. Document Findings @@ -86,8 +85,8 @@ Update the side note with: - What was confirmed vs. disproved - Updated hypothesis for the next iteration -This is critical for not losing context across attempts. Hard bugs typically take -3-5 rounds. Without notes, you'll forget what you ruled out and waste runs +This is critical for not losing context across attempts. Hard bugs typically +take 3-5 rounds. Without notes, you'll forget what you ruled out and waste runs re-checking things. ### 6. Iterate @@ -105,30 +104,31 @@ notice yourself drifting toward any of them, stop and return to the cycle. ### Jumping to fixes without evidence -The most common failure. You have a plausible theory, so you "fix" it and run again. -If the theory was wrong, you've added complexity, wasted a test run, and possibly -introduced a new bug. The side note should always show "hypothesis verified by -[specific data]" before any fix is applied. +The most common failure. You have a plausible theory, so you "fix" it and run +again. If the theory was wrong, you've added complexity, wasted a test run, and +possibly introduced a new bug. The side note should always show "hypothesis +verified by [specific data]" before any fix is applied. ### Blaming external systems "The model is hallucinating." "The API is flaky." "The library has a bug." These -conclusions feel satisfying because they put the problem outside your control. They're -also usually wrong. +conclusions feel satisfying because they put the problem outside your control. +They're also usually wrong. -Before blaming an external system, inspect what it actually received. A model that -appears to hallucinate may be responding rationally to stale data you didn't know -was there. An API that appears flaky may be receiving malformed requests. Look at -the inputs, not just the outputs. +Before blaming an external system, inspect what it actually received. A model +that appears to hallucinate may be responding rationally to stale data you +didn't know was there. An API that appears flaky may be receiving malformed +requests. Look at the inputs, not just the outputs. ### Inspecting code paths but not data -You instrument the code and prove it executes correctly — the right functions are -called, in the right order, with no errors. But the bug persists. Why? +You instrument the code and prove it executes correctly — the right functions +are called, in the right order, with no errors. But the bug persists. Why? -Because the code can work perfectly while processing garbage input. A function that -correctly reads an inbox, correctly delivers messages, and correctly formats output -is still broken if the inbox contains stale messages from a previous run. +Because the code can work perfectly while processing garbage input. A function +that correctly reads an inbox, correctly delivers messages, and correctly +formats output is still broken if the inbox contains stale messages from a +previous run. Always inspect the _content_ flowing through the code, not just whether the code runs. Check payloads, message contents, file data, and database state. @@ -136,19 +136,18 @@ runs. Check payloads, message contents, file data, and database state. ### Reframing the user's report instead of investigating it When the user reports a symptom your own run doesn't reproduce, the -contradiction _is_ the evidence — the two environments differ in some way -you haven't identified yet. The wrong move is to reframe their report -("they must be on a stale SHA", "they must be confused about what they -saw", "must be a flake") so that your run becomes the ground truth. Once -you do that, every later piece of evidence gets bent to defend the -reframing, and the actual bug stays hidden. +contradiction _is_ the evidence — the two environments differ in some way you +haven't identified yet. The wrong move is to reframe their report ("they must be +on a stale SHA", "they must be confused about what they saw", "must be a flake") +so that your run becomes the ground truth. Once you do that, every later piece +of evidence gets bent to defend the reframing, and the actual bug stays hidden. -The right move: catalogue what differs between their environment and -yours (TTY vs pipe, terminal emulator, shell, locale, env vars, prior -state, build artifacts) before forming any hypothesis. For ambiguous -symptoms ("no output", "it's slow", "it's wrong") ask one disambiguating -question first — e.g., "does it hang or exit cleanly?" — that prunes the -hypothesis space cheaply before any test run. +The right move: catalogue what differs between their environment and yours (TTY +vs pipe, terminal emulator, shell, locale, env vars, prior state, build +artifacts) before forming any hypothesis. For ambiguous symptoms ("no output", +"it's slow", "it's wrong") ask one disambiguating question first — e.g., "does +does it hang or exit cleanly?" That prunes the hypothesis space before any +test run. ### Losing context across attempts @@ -161,9 +160,9 @@ a new round, re-read it first. ## Persistent State: A Special Category -Features that persist data across runs — caches, session recordings, message queues, -temp files, database rows — are a frequent source of "impossible" bugs. The current -run's behavior is contaminated by leftover state from previous runs. +Features that persist data across runs — caches, session recordings, message +queues, temp files, and database rows often cause "impossible" bugs. +The current run's behavior is contaminated by leftover state from previous runs. When behavior seems irrational, always check: @@ -175,7 +174,7 @@ This is easy to miss because the code is correct — it's the data that's wrong. ## When to Exit the Cycle -Apply the fix when — and only when — you can point to specific data from your +Apply the fix only when you can point to specific data from your instrumentation that confirms the root cause. Write in the side note: ``` @@ -188,7 +187,7 @@ Then apply the fix, remove instrumentation, and verify with a clean run. ## Worked examples -- [`examples/headless-bg-agent-empty-stdout.md`](examples/headless-bg-agent-empty-stdout.md) +- `examples/headless-bg-agent-empty-stdout.md` — pipe-captured runs all passed; the user's TTY printed nothing. The - contradiction _was_ the bug. Illustrates _reproduction contradiction is - data_ and _instrument data, not code paths_. + contradiction _was_ the bug. Illustrates _reproduction contradiction is data_ + and _instrument data, not code paths_. diff --git a/.qwen/skills/structured-debugging/examples/headless-bg-agent-empty-stdout.md b/.qwen/skills/structured-debugging/examples/headless-bg-agent-empty-stdout.md index 33a356b76..d31fad9ff 100644 --- a/.qwen/skills/structured-debugging/examples/headless-bg-agent-empty-stdout.md +++ b/.qwen/skills/structured-debugging/examples/headless-bg-agent-empty-stdout.md @@ -1,59 +1,58 @@ # Worked example: headless run prints empty stdout in zsh TTY A short qwen-code case to illustrate two failure modes from `SKILL.md`: -_reproduction contradiction is data_, and _instrument the data flow, not -just the code path_. +_reproduction contradiction is data_, and _instrument the data flow, not just +the code path_. ## The bug User: `npm run dev -- -p "..."` in zsh prints nothing. Process exits clean, `~/.qwen/logs` shows the model returned proper text. Stdout was empty. -Cause: `JsonOutputAdapter.emitResult` wrote `resultMessage.result` without -a trailing `\n`. zsh's `PROMPT_SP` (powerlevel10k, agnoster, …) detects -the missing newline and emits `\r\033[K` before drawing the next prompt, -erasing the line. Pipe-captured stdout has no `PROMPT_SP`, so the bug is -invisible there. +Cause: `JsonOutputAdapter.emitResult` wrote `resultMessage.result` without a +trailing `\n`. zsh's `PROMPT_SP` (powerlevel10k, agnoster, …) detects the +missing newline and emits `\r\033[K` before drawing the next prompt, erasing the +line. Pipe-captured stdout has no `PROMPT_SP`, so the bug is invisible there. Fix: append `\n` to the write. ## What made the case instructive -Every reproduction attempt from a debugging environment that captures -stdout (Cursor's Shell tool, `out=$(...)`, `tee`, file redirect) **passed**. -14/14 success against the user's 0/N. Same SHA, same machine, same -command. The only variable was: pipe stdout vs TTY stdout. +Every reproduction attempt from a debugging environment that captures stdout +(Cursor's Shell tool, `out=$(...)`, `tee`, file redirect) **passed**. 14/14 +success against the user's 0/N. Same SHA, same machine, same command. The only +variable was: pipe stdout vs TTY stdout. -That contradiction was the entire investigation. Once it was named, the -fix was one line. +That contradiction was the entire investigation. Once it was named, the fix was +one line. ## Lessons mapped to SKILL.md - **Reproduction contradiction is data, not user error.** When your run - succeeds and the user's fails on identical state, the _difference - between the two environments_ is where the bug lives. Catalogue what - differs (TTY vs pipe, terminal emulator, shell, locale, env vars, - prior state) before forming any hypothesis. Reframing the user's - report ("they must be on stale code") burns rounds and credibility. + succeeds and the user's fails on identical state, the _difference between the + two environments_ is where the bug lives. Catalogue what differs (TTY vs pipe, + terminal emulator, shell, locale, env vars, prior state) before forming any + hypothesis. Reframing the user's report ("they must be on stale code") burns + rounds and credibility. - **Ask the one disambiguating question first.** "Does it hang or exit - cleanly?" would have falsified the most tempting wrong hypothesis here - (the recently-fixed drain-loop hang) on turn one. For any "no output" - report, that question is free and prunes half the hypothesis space. + cleanly?" would have falsified the most tempting wrong hypothesis here (the + recently-fixed drain-loop hang) on turn one. For any "no output" report, that + question is free and prunes half the hypothesis space. -- **Instrument the data flow, not just the code path.** Tracing whether - `write` was called showed the happy path firing every time and resolved - nothing. The breakthrough was logging the _return value_ of - `process.stdout.write` together with `process.stdout.isTTY`. Code-path - traces tell you what ran; data traces tell you what it ran on. +- **Instrument the data flow, not just the code path.** Tracing whether `write` + was called showed the happy path firing every time and resolved nothing. The + breakthrough was logging the _return value_ of `process.stdout.write` together + with `process.stdout.isTTY`. Code-path traces tell you what ran; data traces + tell you what it ran on. -- **Pipe ≠ TTY.** A passing pipe-captured run does not prove a TTY user - sees the same output. Shell prompts can post-process trailing-newline- - less writes; terminals can swallow control sequences; pipes do - neither. When debugging interactive-shell symptoms, get evidence from - the user's actual terminal at least once. +- **Pipe ≠ TTY.** A passing pipe-captured run does not prove a TTY user sees + the same output. Shell prompts can post-process trailing-newline- less writes; + terminals can swallow control sequences; pipes do neither. When debugging + interactive-shell symptoms, get evidence from the user's actual terminal at + least once. ## Reference -Fix commit: qwen-code `feadf052f` — -`fix(cli): append newline to text-mode emitResult so zsh PROMPT_SP doesn't erase the line` +Fix commit: qwen-code `feadf052f` — `fix(cli): append newline to text-mode +emitResult so zsh PROMPT_SP doesn't erase the line` diff --git a/.qwen/skills/terminal-capture/SKILL.md b/.qwen/skills/terminal-capture/SKILL.md index a9ac59f45..3fba541ab 100644 --- a/.qwen/skills/terminal-capture/SKILL.md +++ b/.qwen/skills/terminal-capture/SKILL.md @@ -1,35 +1,44 @@ --- name: terminal-capture -description: Automates terminal UI screenshot testing for CLI commands. Applies when reviewing PRs that affect CLI output, testing slash commands (/about, /context, /auth, /export), generating visual documentation, or when 'terminal screenshot', 'CLI test', 'visual test', or 'terminal-capture' is mentioned. +description: Automates terminal UI screenshot testing for CLI commands. Applies + when reviewing PRs that affect CLI output, testing slash commands (/about, + /context, /auth, /export), generating visual documentation, or when 'terminal + screenshot', 'CLI test', 'visual test', or 'terminal-capture' is mentioned. --- # Terminal Capture — CLI Terminal Screenshot Automation -Drive terminal interactions and screenshots via TypeScript configuration, used for visual verification during PR reviews. +Drive terminal interactions and screenshots via TypeScript configuration, used +for visual verification during PR reviews. ## Prerequisites Ensure the following dependencies are installed before running: ```bash -npm install # Install project dependencies (including node-pty, xterm, playwright, etc.) +npm install # Install project dependencies. npx playwright install chromium # Install Playwright browser ``` ## Architecture ``` -node-pty (pseudo-terminal) → ANSI byte stream → xterm.js (Playwright headless) → Screenshot +node-pty (pseudo-terminal) + → ANSI byte stream + → xterm.js (Playwright headless) + → Screenshot ``` Core files: -| File | Purpose | -| -------------------------------------------------------- | ------------------------------------------------------------------------ | -| `integration-tests/terminal-capture/terminal-capture.ts` | Low-level engine (PTY + xterm.js + Playwright) | -| `integration-tests/terminal-capture/scenario-runner.ts` | Scenario executor (parses config, drives interactions, auto-screenshots) | -| `integration-tests/terminal-capture/run.ts` | CLI entry point (batch run scenarios) | -| `integration-tests/terminal-capture/scenarios/*.ts` | Scenario configuration files | +- `integration-tests/terminal-capture/terminal-capture.ts` + Low-level PTY, xterm.js, and Playwright engine. +- `integration-tests/terminal-capture/scenario-runner.ts` + Scenario executor for config, interactions, and screenshots. +- `integration-tests/terminal-capture/run.ts` + CLI entry point for batch scenario runs. +- `integration-tests/terminal-capture/scenarios/*.ts` + Scenario configuration files. ## Quick Start @@ -43,7 +52,8 @@ import type { ScenarioConfig } from '../scenario-runner.js'; export default { name: '/about', spawn: ['node', 'dist/cli.js', '--yolo'], - terminal: { title: 'qwen-code', cwd: '../../..' }, // Relative to this config file's location + // cwd is relative to this config file's location. + terminal: { title: 'qwen-code', cwd: '../../..' }, flow: [ { type: 'Hi, can you help me understand this codebase?' }, { type: '/about' }, @@ -55,15 +65,18 @@ export default { ```bash # Single scenario -npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts +npx tsx integration-tests/terminal-capture/run.ts \ + integration-tests/terminal-capture/scenarios/about.ts # Batch (entire directory) -npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ +npx tsx integration-tests/terminal-capture/run.ts \ + integration-tests/terminal-capture/scenarios/ ``` ### 3. Output -Screenshots are saved to `integration-tests/terminal-capture/scenarios/screenshots/{name}/`: +Screenshots are saved to +`integration-tests/terminal-capture/scenarios/screenshots/{name}/`: | File | Description | | --------------- | ---------------------------------- | @@ -79,7 +92,8 @@ Each flow step can contain the following fields: ### `type: string` — Input Text -Automatic behavior: Input text → Screenshot (01) → Press Enter → Wait for output to stabilize → Screenshot (02). +Automatic behavior: +Input text → Screenshot (01) → Enter → stable output → Screenshot (02). ```typescript { @@ -90,13 +104,17 @@ Automatic behavior: Input text → Screenshot (01) → Press Enter → Wait for } // Slash command (auto-completion handled automatically) ``` -**Special rule**: If the next step is `key`, do not auto-press Enter (hand over control to the key sequence). +**Special rule**: If the next step is `key`, do not auto-press Enter (hand over +control to the key sequence). ### `key: string | string[]` — Send Key Press -Used for menu selection, Tab completion, and other interactions. Does not auto-press Enter or auto-screenshot. +Used for menu selection, Tab completion, and other interactions. Does not +auto-press Enter or auto-screenshot. -Supported key names: `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Enter`, `Tab`, `Escape`, `Backspace`, `Space`, `Home`, `End`, `PageUp`, `PageDown`, `Delete` +Supported key names: `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Enter`, +`Tab`, `Escape`, `Backspace`, `Space`, `Home`, `End`, `PageUp`, `PageDown`, +`Delete` ```typescript { @@ -107,11 +125,13 @@ Supported key names: `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Enter`, } // Multiple keys ``` -Auto-screenshot is triggered after the key sequence ends (when the next step is not a `key`). +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. +Capture multiple screenshots at intervals during long-running output (e.g., +progress bars). Optionally generates an animated GIF. ```typescript { @@ -125,11 +145,15 @@ Capture multiple screenshots at intervals during long-running output (e.g., prog } ``` -- `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. +- `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: +**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 @@ -139,7 +163,8 @@ which ffmpeg brew install ffmpeg ``` -If the user declines, the scenario still runs — GIF generation is skipped with a warning. +If the user declines, the scenario still runs. GIF generation is skipped with a +warning. ### `capture` / `captureFull` — Explicit Screenshot @@ -200,12 +225,18 @@ This tool is commonly used for visual verification during PR reviews. ## Troubleshooting -| Issue | Cause | Solution | -| ------------------------------------ | ------------------------------------- | ---------------------------------------------------- | -| Playwright error `browser not found` | Browser not installed | `npx playwright install chromium` | -| Blank screenshot | Process starts slowly or build failed | Ensure `npm run build` succeeds, check spawn command | -| PTY-related errors | node-pty native module not compiled | `npm rebuild node-pty` | -| Unstable screenshot output | Terminal output not fully rendered | Check if the scenario needs additional wait time | +- Playwright error `browser not found` + Cause: browser not installed. + Solution: `npx playwright install chromium`. +- Blank screenshot + Cause: process starts slowly or build failed. + Solution: check build success and the spawn command. +- PTY-related errors + Cause: node-pty native module not compiled. + Solution: `npm rebuild node-pty`. +- Unstable screenshot output + Cause: terminal output not fully rendered. + Solution: add scenario wait time. ## Full ScenarioConfig Type diff --git a/AGENTS.md b/AGENTS.md index c45bc51e0..9deb36d68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # AGENTS.md -This file provides guidance to Qwen Code when working with code in this repository. +This file provides guidance to Qwen Code when working with code in this +repository. ## Common Commands @@ -10,14 +11,27 @@ This file provides guidance to Qwen Code when working with code in this reposito npm install # Install all dependencies npm run build # Build all packages (TypeScript compilation + asset copying) npm run build:all # Build everything including sandbox container -npm run bundle # Bundle dist/ into a single dist/cli.js via esbuild (requires build first) +npm run bundle # Bundle dist/ into a single dist/cli.js via esbuild + # (requires build first) ``` -`npm run build` compiles TS into each package's `dist/`. `npm run bundle` takes that output and produces a single `dist/cli.js` via esbuild. Bundle requires build to have run first. +`npm run build` compiles TS into each package's `dist/`. `npm run bundle` +takes that output and produces a single `dist/cli.js` via esbuild. Bundle +requires build to have run first. + +### Development + +```bash +npm run dev # Run CLI directly from TypeScript source (no build needed) +``` + +Runs the CLI via `tsx` with `DEV=true`. Changes to `packages/core` or +`packages/cli` are reflected immediately without rebuilding. ### Unit Testing -Tests must be run from within the specific package directory, not the project root. +Tests must be run from within the specific package directory, not the project +root. **Run individual test files** (always preferred): @@ -35,12 +49,14 @@ cd packages/cli && npx vitest run src/path/to/file.test.ts --update **Avoid:** - `npm run test -- --filter=...` — does NOT filter; runs the entire suite -- `npx vitest` from the project root — fails due to package-specific vitest configs +- `npx vitest` from the project root — fails due to package-specific vitest + configs - Running the whole test suite unless necessary (e.g., final PR verification) **Test gotchas:** -- In CLI tests, use `vi.hoisted()` for mocks consumed by `vi.mock()` — the mock factory runs at module load time, before test execution. +- In CLI tests, use `vi.hoisted()` for mocks consumed by `vi.mock()` — the + mock factory runs at module load time, before test execution. ### Integration Testing @@ -56,10 +72,12 @@ npm run test:integration:interactive:sandbox:none Or combined in one command: ```bash -cd integration-tests && cross-env QWEN_SANDBOX=false npx vitest run cli interactive +cd integration-tests && \ + cross-env QWEN_SANDBOX=false npx vitest run cli interactive ``` -**Gotcha:** In interactive tests, always call `session.idle()` between sends — ANSI output streams asynchronously. +**Gotcha:** In interactive tests, always call `session.idle()` between sends — +ANSI output streams asynchronously. ### Linting & Formatting @@ -68,25 +86,91 @@ npm run lint # ESLint check npm run lint:fix # Auto-fix lint issues npm run format # Prettier formatting npm run typecheck # TypeScript type checking -npm run preflight # Full check: clean → install → format → lint → build → typecheck → test +npm run preflight # Full check: clean → install → format → lint → build + # → typecheck → test ``` ## Code Conventions - **Module system**: ESM throughout (`"type": "module"` in all packages) -- **TypeScript**: Strict mode with `noImplicitAny`, `strictNullChecks`, `noUnusedLocals`, `verbatimModuleSyntax` -- **Formatting**: Prettier — single quotes, semicolons, trailing commas, 2-space indent, 80-char width -- **Linting**: No `any` types, consistent type imports, no relative imports between packages -- **Tests**: Collocated with source (`file.test.ts` next to `file.ts`), vitest framework +- **TypeScript**: Strict mode with `noImplicitAny`, `strictNullChecks`, + `noUnusedLocals`, `verbatimModuleSyntax` +- **Formatting**: Prettier — single quotes, semicolons, trailing commas, + 2-space indent, 80-char width +- **Linting**: No `any` types, consistent type imports, no relative imports + between packages +- **Tests**: Collocated with source (`file.test.ts` next to `file.ts`), + vitest framework - **Commits**: Conventional Commits (e.g., `feat(cli): Add --json flag`) - **Node.js**: Development requires `~20.19.0`; production requires `>=20` +## Development Guidelines + +### General workflow + +1. **Design doc for non-trivial work** — write one in `.qwen/design/` if the + change touches multiple files or involves design decisions. Skip for small + bugfixes. +2. **Test plan for behavioral changes** — write an E2E test plan in + `.qwen/e2e-tests/` when the change affects user-observable behavior. Dry-run + against the global `qwen` CLI first to confirm the baseline. +3. **Build + typecheck before declaring done**: + `npm run build && npm run typecheck`. +4. **Code review** — run `/review` when available. Triage each comment: + valid / false positive / overthinking. + +### Feature development + +Use the `/feat-dev` skill for the full workflow: investigate, design, test plan, +dry-run, implement, verify, code review, and iterate. + +### Bugfix + +Use the `/bugfix` skill for the reproduce-first workflow: reproduce, fix, +verify, test, and code review. + ## GitHub Operations -Use the `gh` CLI for all GitHub-related operations — issues, pull requests, comments, CI checks, releases, and API calls. Prefer `gh issue view`, `gh pr view`, `gh pr checks`, `gh run view`, `gh api`, etc. over web fetches or manual REST calls. +Use the `gh` CLI for all GitHub-related operations — issues, pull requests, +comments, CI checks, releases, and API calls. Prefer `gh issue view`, +`gh pr view`, `gh pr checks`, `gh run view`, `gh api`, etc. over web fetches +or manual REST calls. ## Testing, Debugging, and Bug Fixes -- **Bug reproduction & verification**: spawn the `test-engineer` agent. It reads code and docs to understand the bug, then reproduces it via E2E testing (or a test-script fallback). It also handles post-fix verification. It cannot edit source code — only observe and report. -- **Hard bugs**: use the `structured-debugging` skill when debugging requires more than a quick glance — especially when the first attempt at a fix didn't work or the behavior seems impossible. -- **E2E testing**: the `e2e-testing` skill covers headless mode, interactive (tmux) mode, MCP server testing, and API traffic inspection. The `test-engineer` agent invokes this skill internally — you typically don't need to use it directly. +- **Bug reproduction & verification**: spawn the `test-engineer` agent. It + reads code and docs to understand the bug, then reproduces it via E2E testing + (or a test-script fallback). It also handles post-fix verification. It cannot + edit source code — only observe and report. +- **Hard bugs**: use the `structured-debugging` skill when debugging requires + more than a quick glance — especially when the first attempt at a fix didn't + work or the behavior seems impossible. +- **E2E testing**: the `e2e-testing` skill covers headless mode, interactive + (tmux) mode, MCP server testing, and API traffic inspection. The + `test-engineer` agent invokes this skill internally — you typically don't + need to use it directly. + +## Submitting PRs + +When creating a PR, follow the template at `.github/pull_request_template.md`. +After the PR is submitted, post a separate comment with the E2E test report if +applicable. + +- **PR description**: explain the motivation and changes in prose. Avoid + referencing file names or function names. +- **Reviewer Test Plan**: describe behaviors a reviewer should verify and what + to expect, not scripted test commands. + +## Project Directories + +Project artifacts live under `.qwen/`: + +| Directory | Purpose | +| ----------------------- | ------------------------------------ | +| `.qwen/design/` | Design docs for planned features | +| `.qwen/e2e-tests/` | E2E test plans and results | +| `.qwen/issues/` | Issue drafts before filing on GitHub | +| `.qwen/pr-drafts/` | PR drafts before submitting | +| `.qwen/pr-reviews/` | PR review notes | +| `.qwen/investigations/` | Structured debugging journals | +| `.qwen/scripts/` | Utility scripts | diff --git a/README.md b/README.md index b56c72120..5358f9d0e 100644 --- a/README.md +++ b/README.md @@ -424,7 +424,7 @@ As an open-source terminal agent, you can use Qwen Code in four primary ways: 1. Interactive mode (terminal UI) 2. Headless mode (scripts, CI) 3. IDE integration (VS Code, Zed) -4. TypeScript SDK +4. SDKs (TypeScript, Python, Java) #### Interactive mode @@ -452,11 +452,38 @@ Use Qwen Code inside your editor (VS Code, Zed, and JetBrains IDEs): - [Use in Zed](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/) - [Use in JetBrains IDEs](https://qwenlm.github.io/qwen-code-docs/en/users/integration-jetbrains/) -#### TypeScript SDK +#### SDKs -Build on top of Qwen Code with the TypeScript SDK: +Build on top of Qwen Code with the available SDKs: -- [Use the Qwen Code SDK](./packages/sdk-typescript/README.md) +- TypeScript: [Use the Qwen Code SDK](./packages/sdk-typescript/README.md) +- Python: [Use the Python SDK](./packages/sdk-python/README.md) +- Java: [Use the Java SDK](./packages/sdk-java/qwencode/README.md) + +Python SDK example: + +```python +import asyncio + +from qwen_code_sdk import is_sdk_result_message, query + + +async def main() -> None: + result = query( + "Summarize the repository layout.", + { + "cwd": "/path/to/project", + "path_to_qwen_executable": "qwen", + }, + ) + + async for message in result: + if is_sdk_result_message(message): + print(message["result"]) + + +asyncio.run(main()) +``` ## Commands & Shortcuts diff --git a/docs/design/session-title/session-title-design.md b/docs/design/session-title/session-title-design.md new file mode 100644 index 000000000..7f439a393 --- /dev/null +++ b/docs/design/session-title/session-title-design.md @@ -0,0 +1,376 @@ +# Session Title Design + +> A 3-7 word sentence-case session title generated by the fast model after +> the first assistant turn. Persisted in the session JSONL with a +> `titleSource: 'auto' | 'manual'` tag, surfaced in the session picker, +> and regeneratable on demand via `/rename --auto`. + +## Overview + +`/rename` (#3093) lets a user label a session so they can find it again in +the picker later, but until they run it the picker shows the first user +prompt — often truncated mid-sentence, or describing a framing question +rather than what the session actually became about. Manual renaming is +optional friction most users never do. + +The goal is to make session names _useful by default_: + +- **Descriptive** of what the session actually accomplished, not just the + opening line. 3-7 words, sentence case, git-commit-subject style. +- **Best-effort**: fires in the background after the first reply; if it + fails the user never sees an error. +- **Deferential to the user**: never clobber a `/rename` title the user + chose deliberately, even across CLI tabs on the same session. +- **Explicitly regeneratable** via `/rename --auto` for the "auto title + became stale / I want a fresh one" case. + +## Triggers + +| Trigger | Conditions | Implementation | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| **Auto** | After `recordAssistantTurn` fires. Skipped if an existing title is set, another attempt is in-flight, cap reached, non-interactive, env disabled, or no fast model. | `ChatRecordingService.maybeTriggerAutoTitle` — fire-and-forget | +| **Manual** | User runs `/rename --auto` | `renameCommand.ts` via `tryGenerateSessionTitle` | + +Both paths funnel into a single function — `tryGenerateSessionTitle(config, +signal)` — to guarantee identical prompt, schema, model selection, and +sanitization. The auto trigger is a best-effort background call; the +manual `/rename --auto` is a blocking user action that surfaces a +reason-specific error on failure. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ packages/core/src/services/ │ +│ │ +│ ┌──────────────────────────┐ │ +│ │ chatRecordingService.ts │ │ +│ │ │ │ +│ │ recordAssistantTurn() │ │ +│ │ │ │ │ +│ │ ↓ │ │ +│ │ maybeTriggerAutoTitle() │── 6 guards ──→ IIFE(autoTitleController) │ +│ │ │ │ │ │ +│ │ └── resume hydrate │ ↓ │ +│ │ via │ tryGenerateSessionTitle │ +│ │ getSessionTitle- │ (sessionTitle.ts) │ +│ │ Info │ │ │ +│ │ │ ↓ │ +│ └──────────────────────────┘ BaseLlmClient.generateJson │ +│ (fastModel + JSON schema) │ +│ │ │ +│ ┌──────────────────────────┐ ↓ │ +│ │ sessionService.ts │ sanitizeTitle + sanity checks │ +│ │ │ │ │ +│ │ getSessionTitleInfo() │◀── cross-process ↓ │ +│ │ uses │ re-read recordCustomTitle │ +│ │ readLastJsonString- │ before write (…, 'auto') │ +│ │ FieldsSync │ │ +│ │ (sessionStorageUtils) │ │ +│ └──────────────────────────┘ │ +│ │ +│ ┌─────────────────────┐ │ +│ │ utils/terminalSafe │ │ +│ │ stripTerminalCtrl- │ │ +│ │ Sequences │ │ +│ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────┐ +│ packages/cli/src/ui/ │ +│ │ +│ commands/renameCommand.ts ─── /rename → manual │ +│ ─── /rename → kebab │ +│ ─── /rename --auto → auto │ +│ ─── /rename -- --literal → manual │ +│ ─── /rename --unknown-flag → error │ +│ │ +│ components/SessionPicker.tsx ── dims rows where │ +│ session.titleSource === 'auto' │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Files + +| File | Responsibility | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `packages/core/src/services/sessionTitle.ts` | One-shot LLM call + history filter + sanitize. Exports `tryGenerateSessionTitle`. | +| `packages/core/src/services/chatRecordingService.ts` | `maybeTriggerAutoTitle` trigger, guards, cross-process re-read, abort-on-finalize. | +| `packages/core/src/services/sessionService.ts` | `getSessionTitleInfo` public accessor; `renameSession` accepts `titleSource`. | +| `packages/core/src/utils/sessionStorageUtils.ts` | `extractLastJsonStringFields` + `readLastJsonStringFieldsSync` atomic pair reader. | +| `packages/core/src/utils/terminalSafe.ts` | `stripTerminalControlSequences` shared by sentence-case and kebab paths. | +| `packages/cli/src/ui/commands/renameCommand.ts` | `/rename --auto`, sentinel parser, failure-reason message map. | +| `packages/cli/src/ui/components/SessionPicker.tsx` | Dim styling for `titleSource === 'auto'`. | + +## Prompt Design + +### System Prompt + +Replaces the main agent's system prompt for this single call so the model +only tries to label the session, not behave as a coding assistant. + +Bullets below correspond 1:1 with `TITLE_SYSTEM_PROMPT`: + +- 3-7 words, sentence case (only first word and proper nouns capitalized). +- No trailing punctuation, no markdown, no quotes. +- Match the dominant language of the conversation; for Chinese, budget + roughly 12-20 characters. +- Be specific about the user's actual goal — name the feature, bug, or + subject area. Avoid vague catch-alls like "Code changes" or "Help + request". +- Four good examples (three English + one Chinese) and four bad examples + (too vague / too long / wrong case / trailing punctuation). +- Return only a JSON object with a single `title` key. + +### Structured Output (JSON schema) + +Instead of wrapping output in tags (as session-recap does), we use +`BaseLlmClient.generateJson` with a function-calling schema: + +```ts +const TITLE_SCHEMA = { + type: 'object', + properties: { + title: { + type: 'string', + description: + 'A concise sentence-case session title, 3-7 words, no trailing punctuation.', + }, + }, + required: ['title'], +}; +``` + +Why function calling rather than free text + tag extraction: + +1. Cross-provider reliability — OpenAI-compatible endpoints, Gemini, and + Qwen's native tool-calling all implement function calling; tag parsing + would rely on every model respecting a text convention. +2. No reasoning-preamble leakage — the function call arguments come back + structured, so a "thinking" paragraph before the answer can't bleed + into the title. +3. Simpler post-processing — a single `typeof result.title === 'string'` + check plus `sanitizeTitle` covers every realistic model drift. + +The model may still return something the schema allows but the UX +rejects (empty string, whitespace-only, 500 chars, markdown fencing, +control chars). `sanitizeTitle` handles all of these and returns `''` → +service returns `{ok: false, reason: 'empty_result'}`. + +### Call Parameters + +| Parameter | Value | Reason | +| ----------------- | ------------------------------ | ----------------------------------------------------------------------------------------------- | +| `model` | `getFastModel()` — no fallback | Auto-titling on main-model tokens is too expensive to be silent. | +| `schema` | `TITLE_SCHEMA` | Forces `{title: string}`; filters shape drift at the transport layer. | +| `maxOutputTokens` | `100` | More than enough for 7 words plus schema overhead. | +| `temperature` | `0.2` | Mostly deterministic — session titles benefit from stability across regeneration. | +| `maxAttempts` | `1` | Titles are best-effort cosmetic metadata; retries would queue behind user-visible main traffic. | + +Contrast with session-recap, which falls back to the main model. Title +generation is triggered automatically and often; silently spending +main-model tokens without a user opt-in is a real bill surprise. Manual +`/rename --auto` explicitly fails with `no_fast_model` rather than +fallback — forcing the user to make the fast-model choice consciously. + +## History Filtering + +`geminiClient.getChat().getHistory()` returns `Content[]` that includes +tool calls, tool responses (often 10K+ tokens of file content), and model +thought parts. Feeding that raw into the title LLM would bias the label +toward implementation noise like "Called grep on auth module". + +`filterToDialog` keeps only `user` / `model` entries with non-empty text +and no `thought` / `thoughtSignature` parts. `takeRecentDialog` slices to +the last 20 messages and refuses to start on a dangling model/tool +response. `flattenToTail` converts to "Role: text" lines and slices the +last 1000 characters. + +### The 1000-character tail slice + +A session that starts with `help me debug X` but pivots to refactoring Y +should be titled about Y. Titling by the head locks in the opening +framing; titling by the tail captures what the session became. + +### UTF-16 surrogate handling + +`.slice(-1000)` on a UTF-16 code-unit boundary can orphan a high or low +surrogate if a CJK supplementary char or emoji gets cut. Some providers +respond to the resulting invalid UTF-16 with a 400 — which, without +handling, would burn an attempt for no reason. `flattenToTail` drops a +leading orphaned low surrogate; `sanitizeTitle` scrubs any orphaned +surrogate after the max-length trim on the output path too. + +## Persistence + +### Record shape + +`CustomTitleRecordPayload` grows an optional `titleSource: 'auto' | +'manual'` field: + +```jsonc +{ + "type": "system", + "subtype": "custom_title", + "systemPayload": { + "customTitle": "Debug login button on mobile", + "titleSource": "auto", + }, +} +``` + +The field is optional, and absent-in-legacy records are treated as +`undefined`. `SessionPicker` dims rows only on a strict `=== 'auto'` +match — a pre-change user `/rename` title is never silently reclassified +as a model guess. + +### Resume hydration + +On resume, `ChatRecordingService` constructor calls +`sessionService.getSessionTitleInfo(sessionId)` to read **both** the +title and its source. Without hydrating the source, `finalize()`'s +re-append (which runs on every session lifecycle event) would rewrite +auto as manual on every resume cycle — silently stripping the dim +affordance. + +### Atomic pair read + +`extractLastJsonStringFields` returns `customTitle` and `titleSource` +from the **same matching line** in a single scan. Two separate +`readLastJsonStringFieldSync` calls could land on different records if +an older line has only the primary field, yielding a mismatched pair. +The extractor also requires a proper closing quote on the primary value, +so a crash-truncated trailing record can't win the latest-match race. + +### Full-file scan cap + +Phase-2 (when the tail-window fast path misses) streams the whole file +in 64KB chunks. Capped at `MAX_FULL_SCAN_BYTES = 64 MB` so a corrupt +multi-GB JSONL can't freeze the session picker on the main event loop. +The picker's latency envelope survives corruption. + +### Symlink defense + +Session reads open with `O_NOFOLLOW` (falls back to plain read-only on +Windows, where the constant is not exposed). Defense in depth so a +symlink planted in `~/.qwen/projects//chats/` can't redirect a +metadata read to an unrelated file. + +## Concurrency and Edge Cases + +### Trigger guard order + +`maybeTriggerAutoTitle` checks six conditions in this exact order — each +short-circuits the rest so the cheap ones run first: + +1. `currentCustomTitle` set → skip. Never overwrite manual / prior auto. +2. `autoTitleController !== undefined` → skip. One attempt at a time. +3. `autoTitleAttempts >= 3` → skip. Cap bounds total waste. +4. `!config.isInteractive()` → skip. Headless `qwen -p` / CI never spends + fast-model tokens on a one-shot session. +5. `autoTitleDisabledByEnv()` → skip. `QWEN_DISABLE_AUTO_TITLE=1` + explicit opt-out. +6. `!config.getFastModel()` → skip. No fast-model → no-op. + +### Why the cap is 3, not 1 + +The first assistant turn can be a pure tool-call with no user-visible +text (e.g. the model opens with a `grep`). `tryGenerateSessionTitle` +returns `{ok: false, reason: 'empty_history'}` in that case. Without a +retry window, an entire session's chance at a title would be burned on +turn 1 before the user said anything interesting. Cap of 3 covers the +common "first turn is noise" case while still bounding runaway retry on +a persistently failing fast model. + +### Cross-process manual-rename race + +Two CLI tabs on the same session file can diverge in memory. Tab A runs +`/rename foo` and writes `titleSource: manual`. Tab B's +`ChatRecordingService` has its own `currentCustomTitle = undefined` and +would naively overwrite with an auto title. + +After the LLM call resolves, the IIFE re-reads the JSONL via +`sessionService.getSessionTitleInfo`. If the file shows +`source: 'manual'`, the IIFE bails AND syncs its in-memory state so +subsequent turns respect the rename too. Cost: one 64KB tail read per +successful generation; negligible. + +### Abort propagation on `finalize()` + +`autoTitleController` doubles as the in-flight flag. `finalize()` (run +on session switch and process shutdown) calls +`autoTitleController.abort()` before re-appending the title record. The +LLM socket is cancelled promptly; session switch doesn't wait on a slow +fast-model call. The IIFE's `finally` block clears +`autoTitleController` only if it's still the active one, so a finalize +mid-flight doesn't race a concurrent `recordAssistantTurn`. + +### Manual `/rename` lands mid-flight + +Between the IIFE's `await` completing and the `recordCustomTitle('auto')` +call, the user could `/rename foo`. The IIFE re-checks +`this.currentTitleSource === 'manual'` and bails. The in-process check +AND the cross-process re-read both run; manual wins at both layers. + +## Configuration + +### User-facing knobs + +| Setting / env var | Default | Effect | +| --------------------------- | ------- | --------------------------------------------------------------------------------------------------- | +| `fastModel` | unset | Required for auto-titling. Unset → no-op (no main-model fallback). | +| `QWEN_DISABLE_AUTO_TITLE=1` | unset | Opt out of the auto trigger without unsetting `fastModel`. `/rename --auto` still works on request. | + +No `settings.json` toggle — the env var is the only user-visible +off-switch. Rationale: the feature is cosmetic and cheap; a settings +toggle would add a UI surface for something that can live as a one-time +env export for the few users who want to disable it. + +### Why auto doesn't fall back to the main model + +Auto-titling is triggered unconditionally after every assistant turn. +If a user without a fast model were silently charged main-model tokens +for every new session's title, the cost delta is invisible until the +monthly bill arrives. Failing quietly (no-op, no title, no cost) is the +safer default. `/rename --auto` surfaces `no_fast_model` as an +actionable error so the user can set one if they want to. + +## Observability + +`createDebugLogger('SESSION_TITLE')` emits `debugLogger.warn` from the +generator's catch block. Failures are fully transparent to the user — +auto-title is an auxiliary feature and never throws into the UI. + +Developers can grep for the `[SESSION_TITLE]` tag in the debug log +(`~/.qwen/debug/.txt`; `latest.txt` symlinks to the current +session). A working end-to-end call produces no log output; a failing +one gets one WARN line with the underlying error message. + +## Security Hardening + +The title value is rendered verbatim in the terminal (session picker) +AND persisted in a user-readable JSONL file. Both surfaces are attack +reachable if a compromised or prompt-injected fast model returns +hostile text. + +| Concern | Guard | +| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| ANSI / OSC-8 / CSI injection | `stripTerminalControlSequences` before both JSONL write and picker render. | +| Clickable-link smuggle via OSC-8 | Same — OSC sequences stripped as whole units, not just the ESC byte. | +| Invalid UTF-16 surrogates | Scrubbed in `flattenToTail` (LLM input) and `sanitizeTitle` (LLM output after max-length trim). | +| Subtype-line spoof via user message content | `lineContains: '"subtype":"custom_title"'` — user text that happens to contain the literal phrase can't shadow a real record. | +| Symlink redirect on session reads | `O_NOFOLLOW` (no-op on Windows where the constant is missing). | +| Truncated trailing JSONL record | `extractLastJsonStringFields` requires a closing quote before a record wins the latest-match race. | +| Pathological file size freezing the picker | `MAX_FULL_SCAN_BYTES = 64 MB` cap on Phase-2 full-file scan. | +| Paired CJK bracket decorators (`【Draft】`) | Stripped as a unit so a lone closing bracket doesn't dangle. | + +## Out of Scope + +| Item | Why not | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Auto-regenerate when the title goes stale | `/rename --auto` is the explicit user-triggered path. Silent mid-session title swaps would confuse users scrolling back through the picker. | +| WebUI / VSCode dim-styling parity | Those surfaces read `customTitle` already and will show auto titles as if manual. A follow-up can wire the `titleSource` through. | +| Settings-dialog toggle for auto generation | Env var is the single knob. Full settings UI is easy to add later if user demand surfaces. | +| i18n locale catalog entries for new strings | Consistent with existing `/rename` strings, which fall through to English. A repo-wide i18n pass is out of scope. | +| Migration to re-classify legacy records | Back-compat by design: absent `titleSource` is treated as manual. Rewriting old records would risk losing user intent. | +| Non-interactive auto-titling | `qwen -p` / CI scripts throw the session away; fast-model tokens for a title no one will ever resume is pure waste. | diff --git a/docs/developers/_meta.ts b/docs/developers/_meta.ts index 42c938c9f..ed6597257 100644 --- a/docs/developers/_meta.ts +++ b/docs/developers/_meta.ts @@ -11,7 +11,8 @@ export default { type: 'separator', }, 'sdk-typescript': 'Typescript SDK', - 'sdk-java': 'Java SDK(alpha)', + 'sdk-python': 'Python SDK (alpha)', + 'sdk-java': 'Java SDK (alpha)', 'Dive Into Qwen Code': { title: 'Dive Into Qwen Code', type: 'separator', diff --git a/docs/developers/development/telemetry.md b/docs/developers/development/telemetry.md index 94859048e..006a668a7 100644 --- a/docs/developers/development/telemetry.md +++ b/docs/developers/development/telemetry.md @@ -7,8 +7,7 @@ Learn how to enable and setup OpenTelemetry for Qwen Code. - [OpenTelemetry Integration](#opentelemetry-integration) - [Configuration](#configuration) - [Aliyun Telemetry](#aliyun-telemetry) - - [Prerequisites](#prerequisites) - - [Direct Export (Recommended)](#direct-export-recommended) + - [Manual OTLP Export](#manual-otlp-export) - [Local Telemetry](#local-telemetry) - [File-based Output (Recommended)](#file-based-output-recommended) - [Collector-Based Export (Advanced)](#collector-based-export-advanced) @@ -44,6 +43,11 @@ observability framework — Qwen Code's observability system provides: instrumentation [OpenTelemetry]: https://opentelemetry.io/ +[aliyun-opentelemetry-overview]: https://www.alibabacloud.com/help/en/arms/tracing-analysis/product-overview/what-is-tracing-analysis +[aliyun-opentelemetry-get-started]: https://www.alibabacloud.com/help/en/arms/tracing-analysis/before-you-begin +[aliyun-opentelemetry-console-cn]: https://trace.console.aliyun.com +[aliyun-opentelemetry-console-cn-legacy]: https://tracing.console.aliyun.com +[aliyun-opentelemetry-console-intl]: https://arms.console.alibabacloud.com ## Configuration @@ -54,15 +58,15 @@ observability framework — Qwen Code's observability system provides: All telemetry behavior is controlled through your `.qwen/settings.json` file. These settings can be overridden by environment variables or CLI flags. -| Setting | Environment Variable | CLI Flag | Description | Values | Default | -| -------------- | ------------------------------ | -------------------------------------------------------- | ------------------------------------------------- | ------------------ | ----------------------- | -| `enabled` | `QWEN_TELEMETRY_ENABLED` | `--telemetry` / `--no-telemetry` | Enable or disable telemetry | `true`/`false` | `false` | -| `target` | `QWEN_TELEMETRY_TARGET` | `--telemetry-target ` | Where to send telemetry data | `"qwen"`/`"local"` | `"local"` | -| `otlpEndpoint` | `QWEN_TELEMETRY_OTLP_ENDPOINT` | `--telemetry-otlp-endpoint ` | OTLP collector endpoint | URL string | `http://localhost:4317` | -| `otlpProtocol` | `QWEN_TELEMETRY_OTLP_PROTOCOL` | `--telemetry-otlp-protocol ` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` | -| `outfile` | `QWEN_TELEMETRY_OUTFILE` | `--telemetry-outfile ` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - | -| `logPrompts` | `QWEN_TELEMETRY_LOG_PROMPTS` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs | `true`/`false` | `true` | -| `useCollector` | `QWEN_TELEMETRY_USE_COLLECTOR` | - | Use external OTLP collector (advanced) | `true`/`false` | `false` | +| Setting | Environment Variable | CLI Flag | Description | Values | Default | +| -------------- | ------------------------------ | -------------------------------------------------------- | ------------------------------------------------- | ----------------- | ----------------------- | +| `enabled` | `QWEN_TELEMETRY_ENABLED` | `--telemetry` / `--no-telemetry` | Enable or disable telemetry | `true`/`false` | `false` | +| `target` | `QWEN_TELEMETRY_TARGET` | `--telemetry-target ` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` | +| `otlpEndpoint` | `QWEN_TELEMETRY_OTLP_ENDPOINT` | `--telemetry-otlp-endpoint ` | OTLP collector endpoint | URL string | `http://localhost:4317` | +| `otlpProtocol` | `QWEN_TELEMETRY_OTLP_PROTOCOL` | `--telemetry-otlp-protocol ` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` | +| `outfile` | `QWEN_TELEMETRY_OUTFILE` | `--telemetry-outfile ` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - | +| `logPrompts` | `QWEN_TELEMETRY_LOG_PROMPTS` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs | `true`/`false` | `true` | +| `useCollector` | `QWEN_TELEMETRY_USE_COLLECTOR` | - | Use external OTLP collector (advanced) | `true`/`false` | `false` | **Note on boolean environment variables:** For the boolean settings (`enabled`, `logPrompts`, `useCollector`), setting the corresponding environment variable to @@ -73,21 +77,55 @@ For detailed information about all configuration options, see the ## Aliyun Telemetry -### Direct Export (Recommended) +### Manual OTLP Export -Sends telemetry directly to Aliyun services. No collector needed. +To view Qwen Code telemetry in Alibaba Cloud Managed Service for +OpenTelemetry, configure Qwen Code to export to the OTLP endpoint +provided by ARMS. -1. Enable telemetry in your `.qwen/settings.json`: +Setting `"target": "gcp"` alone does not configure the export +destination. If `otlpEndpoint` is not set, Qwen Code still defaults to +`http://localhost:4317`. If `outfile` is set, it overrides +`otlpEndpoint` and telemetry is written to the file instead of being +sent to Alibaba Cloud. + +1. Enable telemetry in your `.qwen/settings.json` and set the OTLP + endpoint: ```json { "telemetry": { "enabled": true, - "target": "qwen" + "target": "gcp", + "otlpEndpoint": "https://", + "otlpProtocol": "grpc" } } ``` -2. Run Qwen Code and send prompts. -3. View logs and metrics in the Aliyun Console. +2. If your Alibaba Cloud endpoint requires authentication, provide OTLP + headers through standard OpenTelemetry environment variables such as + `OTEL_EXPORTER_OTLP_HEADERS` (or the signal-specific variants). Qwen + Code does not currently expose OTLP auth headers directly in + `.qwen/settings.json`. +3. Run Qwen Code and send prompts. +4. View telemetry in Managed Service for OpenTelemetry: + - Product overview: + [What is Managed Service for OpenTelemetry?][aliyun-opentelemetry-overview] + - Getting started: + [Get started with Managed Service for OpenTelemetry][aliyun-opentelemetry-get-started] + - Console entry points: + - China mainland: + [trace.console.aliyun.com][aliyun-opentelemetry-console-cn] + (legacy console: + [tracing.console.aliyun.com][aliyun-opentelemetry-console-cn-legacy]) + - International: + [arms.console.alibabacloud.com][aliyun-opentelemetry-console-intl] + - In the console, use `Applications` to inspect traces and service + topology. + - To locate the OTLP endpoint and access information: + - **New console** (`trace.console.aliyun.com` or international): + navigate to `Integration Center`. + - **Legacy console** (`tracing.console.aliyun.com`): navigate to + `Cluster Configurations` → `Access point information`. ## Local Telemetry diff --git a/docs/developers/sdk-python.md b/docs/developers/sdk-python.md new file mode 100644 index 000000000..7ef2c0ab6 --- /dev/null +++ b/docs/developers/sdk-python.md @@ -0,0 +1,168 @@ +# Python SDK + +## `qwen-code-sdk` + +`qwen-code-sdk` is an experimental Python SDK for Qwen Code. v1 targets the +existing `stream-json` CLI protocol and keeps the transport surface small and +testable. + +## Scope + +- Package name: `qwen-code-sdk` +- Import path: `qwen_code_sdk` +- Runtime requirement: Python `>=3.10` +- CLI dependency: external `qwen` executable is required in v1 +- Transport scope: process transport only +- Not included in v1: ACP transport, SDK-embedded MCP servers + +## Install + +```bash +pip install qwen-code-sdk +``` + +If `qwen` is not on `PATH`, pass `path_to_qwen_executable` explicitly. + +## Quick Start + +```python +import asyncio + +from qwen_code_sdk import is_sdk_result_message, query + + +async def main() -> None: + result = query( + "Explain the repository structure.", + { + "cwd": "/path/to/project", + "path_to_qwen_executable": "qwen", + }, + ) + + async for message in result: + if is_sdk_result_message(message): + print(message["result"]) + + +asyncio.run(main()) +``` + +## API Surface + +### Top-level entry points + +- `query(prompt, options=None) -> Query` +- `query_sync(prompt, options=None) -> SyncQuery` + +`prompt` supports either: + +- `str` for single-turn requests +- `AsyncIterable[SDKUserMessage]` for multi-turn streams + +### `Query` + +- Async iterable over SDK messages +- `close()` +- `interrupt()` +- `set_model(model)` +- `set_permission_mode(mode)` +- `supported_commands()` +- `mcp_server_status()` +- `get_session_id()` +- `is_closed()` + +### `QueryOptions` + +Supported options in v1: + +- `cwd` +- `model` +- `path_to_qwen_executable` +- `permission_mode` +- `can_use_tool` +- `env` +- `system_prompt` +- `append_system_prompt` +- `debug` +- `max_session_turns` +- `core_tools` +- `exclude_tools` +- `allowed_tools` +- `auth_type` +- `include_partial_messages` +- `resume` +- `continue_session` +- `session_id` +- `timeout` +- `mcp_servers` +- `stderr` + +Session argument priority is fixed as: + +1. `resume` +2. `continue_session` +3. `session_id` + +## Permission Handling + +When the CLI emits a `can_use_tool` control request, the SDK routes it through +`can_use_tool(tool_name, tool_input, context)`. + +- Default behavior: deny +- Default timeout: 60 seconds +- Timeout fallback: deny +- Callback exceptions: converted to deny with an error message +- Callback context: `cancel_event`, `suggestions`, and `blocked_path` +- Callback contract: `can_use_tool` must be async with 3 positional arguments; + `stderr` must accept 1 positional string argument + +## Error Model + +- `ValidationError`: invalid options, invalid UUIDs, unsupported combinations +- `ControlRequestTimeoutError`: initialize, interrupt, or other control request + timed out +- `ProcessExitError`: CLI exited non-zero +- `AbortError`: control request or session was cancelled + +## Troubleshooting + +If the SDK cannot start the CLI: + +- Verify `qwen --version` works in the target environment +- Pass `path_to_qwen_executable` if your shell uses `nvm`, `pyenv`, or other + non-standard PATH setup +- Use `debug=True` or `stderr=print` to surface CLI stderr while debugging + +If session control calls time out: + +- Check that the target `qwen` version supports `--input-format stream-json` +- Increase `timeout.control_request` +- Verify that no wrapper script is swallowing stdout/stderr + +## Repository Integration + +Repository-level helper commands: + +- `npm run test:sdk:python` +- `npm run lint:sdk:python` +- `npm run typecheck:sdk:python` +- `npm run smoke:sdk:python -- --qwen qwen` + +## Real E2E Smoke + +For a real runtime check (actual `qwen` process + real model call), run from +the repository root. The npm helper uses `python3`, so ensure it resolves to a +Python `>=3.10` interpreter: + +```bash +npm run smoke:sdk:python -- --qwen qwen +``` + +This script runs: + +- async single-turn query +- async control flow (`supported_commands`, permission mode updates) +- sync `query_sync` query + +It prints JSON and returns non-zero on failure. diff --git a/docs/developers/tools/introduction.md b/docs/developers/tools/introduction.md index 9c7325552..1dafb14c8 100644 --- a/docs/developers/tools/introduction.md +++ b/docs/developers/tools/introduction.md @@ -46,7 +46,6 @@ Qwen Code's built-in tools can be broadly categorized as follows: - **[File System Tools](./file-system.md):** For interacting with files and directories (reading, writing, listing, searching, etc.). - **[Shell Tool](./shell.md) (`run_shell_command`):** For executing shell commands. - **[Web Fetch Tool](./web-fetch.md) (`web_fetch`):** For retrieving content from URLs. -- **[Web Search Tool](./web-search.md) (`web_search`):** For searching the web. - **[Multi-File Read Tool](./multi-file.md) (`read_many_files`):** A specialized tool for reading content from multiple files or directories, often used by the `@` command. - **[Memory Tool](./memory.md) (`save_memory`):** For saving and recalling information across sessions. - **[Todo Write Tool](./todo-write.md) (`todo_write`):** For creating and managing structured task lists during coding sessions. @@ -58,5 +57,6 @@ Additionally, these tools incorporate: - **[MCP servers](./mcp-server.md)**: MCP servers act as a bridge between the model and your local environment or other services like APIs. - **[MCP Quick Start Guide](../mcp-quick-start.md)**: Get started with MCP in 5 minutes with practical examples - **[MCP Example Configurations](../mcp-example-configs.md)**: Ready-to-use configurations for common scenarios + - **[Web Search via MCP](./web-search.md)**: Connect to web search services (Bailian, Tavily, GLM) through MCP - **[MCP Testing & Validation](../mcp-testing-validation.md)**: Test and validate your MCP server setups - **[Sandboxing](../sandbox.md)**: Sandboxing isolates the model and its changes from your environment to reduce potential risk. diff --git a/docs/developers/tools/web-search.md b/docs/developers/tools/web-search.md index dd1fd7ec6..c55790891 100644 --- a/docs/developers/tools/web-search.md +++ b/docs/developers/tools/web-search.md @@ -1,185 +1,215 @@ -# Web Search Tool (`web_search`) +# Web Search -This document describes the `web_search` tool for performing web searches using multiple providers. +Qwen Code supports web search capabilities through **MCP (Model Context Protocol)** integrations. Rather than a built-in search tool, web search is provided by connecting to external MCP servers, giving you full flexibility to choose the search service that best fits your needs. -## Description +## ⚠️ Breaking Change: Built-in `web_search` Tool Removed -Use `web_search` to perform a web search and get information from the internet. The tool supports multiple search providers and returns a concise answer with source citations when available. +> **Affected versions:** `V0.0.7+` through the last release with built-in web search support. -### Supported Providers +The built-in `web_search` tool and all its associated configuration have been **removed**. If you were using any of the following, you should migrate to the MCP-based approach described in this document: -1. **DashScope** (Official) - Available when explicitly configured in settings (Qwen OAuth free tier auto-injection discontinued 2026-04-15) -2. **Tavily** - High-quality search API with built-in answer generation -3. **Google Custom Search** - Google's Custom Search JSON API +| Removed | What to do | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `webSearch` block in `settings.json` | Configure an MCP server in `mcpServers` instead (see below) | +| `advanced.tavilyApiKey` in `settings.json` | Use the [Tavily MCP server](#tavily-websearch) | +| `TAVILY_API_KEY` environment variable | Use the [Tavily MCP server](#tavily-websearch) | +| `DASHSCOPE_API_KEY` for web search | Use the [Alibaba Cloud Bailian WebSearch MCP](#alibaba-cloud-bailian-websearch-recommended) | +| `GLM_API_KEY` for web search | Use the [GLM WebSearch Prime MCP](#glm-websearch-prime-zhipuai) | +| `--tavily-api-key` / `--glm-api-key` / `--dashscope-api-key` CLI flags | Configure via `mcpServers` in `settings.json` | -### Arguments +### Migration Examples -`web_search` takes two arguments: - -- `query` (string, required): The search query -- `provider` (string, optional): Specific provider to use ("dashscope", "tavily", "google") - - If not specified, uses the default provider from configuration - -## Configuration - -### Method 1: Settings File (Recommended) - -Add to your `settings.json`: +**Before (Tavily via built-in tool):** ```json { "webSearch": { - "provider": [ - { "type": "dashscope" }, - { "type": "tavily", "apiKey": "tvly-xxxxx" }, - { - "type": "google", - "apiKey": "your-google-api-key", - "searchEngineId": "your-search-engine-id" - } - ], + "provider": [{ "type": "tavily", "apiKey": "tvly-xxx" }], + "default": "tavily" + } +} +``` + +**After (Tavily via MCP):** + +```json +{ + "mcpServers": { + "tavily": { + "httpUrl": "https://mcp.tavily.com/mcp/?tavilyApiKey=tvly-xxx" + } + } +} +``` + +--- + +**Before (DashScope via built-in tool):** + +```json +{ + "webSearch": { + "provider": [{ "type": "dashscope", "apiKey": "sk-xxx" }], "default": "dashscope" } } ``` -**Notes:** - -- DashScope doesn't require an API key (official, free service) -- **Qwen OAuth users:** DashScope is automatically added to your provider list, even if not explicitly configured -- Configure additional providers (Tavily, Google) if you want to use them alongside DashScope -- Set `default` to specify which provider to use by default (if not set, priority order: Tavily > Google > DashScope) - -### Method 2: Environment Variables - -Set environment variables in your shell or `.env` file: - -```bash -# Tavily -export TAVILY_API_KEY="tvly-xxxxx" - -# Google -export GOOGLE_API_KEY="your-api-key" -export GOOGLE_SEARCH_ENGINE_ID="your-engine-id" -``` - -### Method 3: Command Line Arguments - -Pass API keys when running Qwen Code: - -```bash -# Tavily -qwen --tavily-api-key tvly-xxxxx - -# Google -qwen --google-api-key your-key --google-search-engine-id your-id - -# Specify default provider -qwen --web-search-default tavily -``` - -### Backward Compatibility (Deprecated) - -⚠️ **DEPRECATED:** The legacy `tavilyApiKey` configuration is still supported for backward compatibility but is deprecated: +**After (Alibaba Cloud Bailian WebSearch via MCP):** ```json { - "advanced": { - "tavilyApiKey": "tvly-xxxxx" // ⚠️ Deprecated + "mcpServers": { + "WebSearch": { + "httpUrl": "https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp", + "headers": { + "Authorization": "Bearer sk-xxx" + } + } } } ``` -**Important:** This configuration is deprecated and will be removed in a future version. Please migrate to the new `webSearch` configuration format shown above. The old configuration will automatically configure Tavily as a provider, but we strongly recommend updating your configuration. +--- -## Disabling Web Search +## Supported MCP Web Search Services -If you want to disable the web search functionality, you can exclude the `web_search` tool in your `settings.json`: +### Alibaba Cloud Bailian WebSearch (Recommended) + +The official web search MCP service provided by Alibaba Cloud Bailian platform, powered by DashScope. + +- **MCP Marketplace:** https://bailian.console.aliyun.com/cn-beijing?tab=mcp#/mcp-market/detail/WebSearch +- **Cost:** Paid (billed via Alibaba Cloud DashScope) +- **Get API Key:** https://help.aliyun.com/zh/model-studio/get-api-key +- **Best for:** Chinese-language queries, access to Chinese web content, integration with the Alibaba Cloud ecosystem + +#### Setup + +**Method 1: CLI command** + +```bash +qwen mcp add WebSearch \ + -t http \ + "https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp" \ + -H "Authorization: Bearer ${DASHSCOPE_API_KEY}" +``` + +**Method 2: `settings.json`** ```json { - "tools": { - "exclude": ["web_search"] + "mcpServers": { + "WebSearch": { + "httpUrl": "https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp", + "headers": { + "Authorization": "Bearer ${DASHSCOPE_API_KEY}" + } + } } } ``` -**Note:** This setting requires a restart of Qwen Code to take effect. Once disabled, the `web_search` tool will not be available to the model, even if web search providers are configured. +Replace `${DASHSCOPE_API_KEY}` with your actual API key, or set it as an environment variable so Qwen Code picks it up automatically. -## Usage Examples +--- -### Basic search (using default provider) +### Tavily WebSearch -``` -web_search(query="latest advancements in AI") +A production-ready MCP server providing real-time web search, extract, map, and crawl capabilities. + +- **Repository:** https://github.com/tavily-ai/tavily-mcp +- **Cost:** Paid (free tier available) +- **Get API Key:** https://app.tavily.com/home +- **Best for:** General-purpose web search with high-quality AI-generated answers + +#### Available Tools + +- `tavily_search` — Real-time web search +- `tavily_extract` — Intelligent data extraction from web pages +- `tavily_map` — Create a structured map of a website +- `tavily_crawl` — Systematically explore websites + +#### Setup + +**Method 1: CLI command (Remote MCP)** + +```bash +qwen mcp add tavily \ + -t http \ + "https://mcp.tavily.com/mcp/?tavilyApiKey=${TAVILY_API_KEY}" ``` -### Search with specific provider +**Method 2: `settings.json` (Remote MCP)** -``` -web_search(query="latest advancements in AI", provider="tavily") +```json +{ + "mcpServers": { + "tavily": { + "httpUrl": "https://mcp.tavily.com/mcp/?tavilyApiKey=${TAVILY_API_KEY}" + } + } +} ``` -### Real-world examples +Replace `${TAVILY_API_KEY}` with your actual API key, or set it as an environment variable. -``` -web_search(query="weather in San Francisco today") -web_search(query="latest Node.js LTS version", provider="google") -web_search(query="best practices for React 19", provider="dashscope") +**Method 3: `settings.json` (Local NPX)** + +```json +{ + "mcpServers": { + "tavily-mcp": { + "command": "npx", + "args": ["-y", "tavily-mcp@latest"], + "env": { + "TAVILY_API_KEY": "your-api-key-here" + } + } + } +} ``` -## Provider Details +--- -### DashScope (Official) +### GLM WebSearch Prime (ZhipuAI) -- **Cost:** Free (requires Qwen OAuth credentials) -- **Authentication:** Requires Qwen OAuth credentials -- **Configuration:** Must be explicitly configured in `settings.json` web search providers (auto-injection for Qwen OAuth users was removed when the free tier was discontinued on 2026-04-15) -- **Quota:** 200 requests/minute, 100 requests/day -- **Best for:** General queries when you have Qwen OAuth credentials +The official web search Remote MCP service provided by ZhipuAI (智谱AI), designed for GLM Coding Plan users. Provides real-time web search including news, stock prices, weather, and more. -### Tavily +- **Documentation:** https://docs.bigmodel.cn/cn/coding-plan/mcp/search-mcp-server +- **Cost:** Included in GLM Coding Plan subscription (Lite: 100 calls/month, Pro: 1,000/month, Max: 4,000/month) +- **Get API Key:** https://open.bigmodel.cn/apikey/platform +- **Best for:** Chinese-language queries, real-time information retrieval -- **Cost:** Requires API key (paid service with free tier) -- **Sign up:** https://tavily.com -- **Features:** High-quality results with AI-generated answers -- **Best for:** Research, comprehensive answers with citations +#### Available Tools -### Google Custom Search +- `webSearchPrime` — Web search returning page title, URL, summary, site name, and favicon -- **Cost:** Free tier available (100 queries/day) -- **Setup:** - 1. Enable Custom Search API in Google Cloud Console - 2. Create a Custom Search Engine at https://programmablesearchengine.google.com -- **Features:** Google's search quality -- **Best for:** Specific, factual queries +#### Setup -## Important Notes +**Method 1: CLI command** -- **Response format:** Returns a concise answer with numbered source citations -- **Citations:** Source links are appended as a numbered list: [1], [2], etc. -- **Multiple providers:** If one provider fails, manually specify another using the `provider` parameter -- **DashScope availability:** Automatically available for Qwen OAuth users, no configuration needed -- **Default provider selection:** The system automatically selects a default provider based on availability: - 1. Your explicit `default` configuration (highest priority) - 2. CLI argument `--web-search-default` - 3. First available provider by priority: Tavily > Google > DashScope +```bash +qwen mcp add web-search-prime \ + -t http \ + "https://open.bigmodel.cn/api/mcp/web_search_prime/mcp" \ + -H "Authorization: Bearer ${GLM_API_KEY}" +``` -## Troubleshooting +**Method 2: `settings.json`** -**Tool not available?** +```json +{ + "mcpServers": { + "web-search-prime": { + "httpUrl": "https://open.bigmodel.cn/api/mcp/web_search_prime/mcp", + "headers": { + "Authorization": "Bearer ${GLM_API_KEY}" + } + } + } +} +``` -- **For Qwen OAuth users:** The tool is automatically registered with DashScope provider, no configuration needed -- **For other authentication types:** Ensure at least one provider (Tavily or Google) is configured -- For Tavily/Google: Verify your API keys are correct +Replace `${GLM_API_KEY}` with your actual ZhipuAI API key, or set it as an environment variable. -**Provider-specific errors?** - -- Use the `provider` parameter to try a different search provider -- Check your API quotas and rate limits -- Verify API keys are properly set in configuration - -**Need help?** - -- Check your configuration: Run `qwen` and use the settings dialog -- View your current settings in `~/.qwen-code/settings.json` (macOS/Linux) or `%USERPROFILE%\.qwen-code\settings.json` (Windows) +--- diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 6dc6d1d02..8ea037779 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -223,7 +223,6 @@ The `extra_body` field allows you to add custom parameters to the request body s | `context.fileFiltering.respectQwenIgnore` | boolean | Respect .qwenignore files when searching. | `true` | | `context.fileFiltering.enableRecursiveFileSearch` | boolean | Whether to enable searching recursively for filenames under the current tree when completing `@` prefixes in the prompt. | `true` | | `context.fileFiltering.enableFuzzySearch` | boolean | When `true`, enables fuzzy search capabilities when searching for files. Set to `false` to improve performance on projects with a large number of files. | `true` | -| `context.clearContextOnIdle.thinkingThresholdMinutes` | number | Minutes of inactivity before clearing old thinking blocks to free context tokens. Aligns with typical provider prompt-cache TTL. Use `-1` to disable. | `5` | | `context.clearContextOnIdle.toolResultsThresholdMinutes` | number | Minutes of inactivity before clearing old tool result content. Use `-1` to disable. | `60` | | `context.clearContextOnIdle.toolResultsNumToKeep` | number | Number of most-recent compactable tool results to preserve when clearing. Floor at 1. | `5` | @@ -430,11 +429,6 @@ LSP server configuration is done through `.lsp.json` files in your project root | `advanced.dnsResolutionOrder` | string | The DNS resolution order. | `undefined` | | `advanced.excludedEnvVars` | array of strings | Environment variables to exclude from project context. Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with the CLI behavior. Variables from `.qwen/.env` files are never excluded. | `["DEBUG","DEBUG_MODE"]` | | `advanced.bugCommand` | object | Configuration for the bug report command. Overrides the default URL for the `/bug` command. Properties: `urlTemplate` (string): A URL that can contain `{title}` and `{info}` placeholders. Example: `"bugCommand": { "urlTemplate": "https://bug.example.com/new?title={title}&info={info}" }` | `undefined` | -| `advanced.tavilyApiKey` | string | API key for Tavily web search service. Used to enable the `web_search` tool functionality. | `undefined` | - -> [!note] -> -> **Note about advanced.tavilyApiKey:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format. #### mcpServers @@ -571,7 +565,6 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe | `CLI_TITLE` | Set to a string to customize the title of the CLI. | | | `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. | | `QWEN_CODE_MAX_OUTPUT_TOKENS` | Overrides the default maximum output tokens per response. When not set, Qwen Code uses an adaptive strategy: starts with 8K tokens and automatically retries with 64K if the response is truncated. Set this to a specific value (e.g., `16000`) to use a fixed limit instead. | Takes precedence over the capped default (8K) but is overridden by `samplingParams.max_tokens` in settings. Disables automatic escalation when set. Example: `export QWEN_CODE_MAX_OUTPUT_TOKENS=16000` | -| `TAVILY_API_KEY` | Your API key for the Tavily web search service. | Used to enable the `web_search` tool functionality. Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` | | `QWEN_CODE_UNATTENDED_RETRY` | Set to `true` or `1` to enable persistent retry mode. When enabled, transient API capacity errors (HTTP 429 Rate Limit and 529 Overloaded) are retried indefinitely with exponential backoff (capped at 5 minutes per retry) and heartbeat keepalives every 30 seconds on stderr. | Designed for CI/CD pipelines and background automation where long-running tasks should survive temporary API outages. Must be set explicitly — `CI=true` alone does **not** activate this mode. See [Headless Mode](../features/headless#persistent-retry-mode) for details. Example: `export QWEN_CODE_UNATTENDED_RETRY=1` | | `QWEN_CODE_PROFILE_STARTUP` | Set to `1` to enable startup performance profiling. Writes a JSON timing report to `~/.qwen/startup-perf/` with per-phase durations. | Only active inside the sandbox child process. Zero overhead when not set. Example: `export QWEN_CODE_PROFILE_STARTUP=1` | @@ -620,7 +613,6 @@ For sandbox image selection, precedence is: | `--version` | | Displays the version of the CLI. | | | | `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. | | `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` | -| `--tavily-api-key` | | Sets the Tavily API key for web search functionality for this session. | API key | Example: `qwen --tavily-api-key tvly-your-api-key-here` | ## Context Files (Hierarchical Instructional Context) diff --git a/docs/users/features/sub-agents.md b/docs/users/features/sub-agents.md index 957262a6a..d4473572e 100644 --- a/docs/users/features/sub-agents.md +++ b/docs/users/features/sub-agents.md @@ -333,7 +333,6 @@ tools: - read_file - write_file - read_many_files - - web_search --- You are a technical documentation specialist. diff --git a/integration-tests/cli/web_search.test.ts b/integration-tests/cli/web_search.test.ts deleted file mode 100644 index 5ab0b4364..000000000 --- a/integration-tests/cli/web_search.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect } from 'vitest'; -import { - TestRig, - printDebugInfo, - validateModelOutput, -} from '../test-helper.js'; - -describe('web_search', () => { - it('should be able to search the web', async () => { - // Check if any web search provider is available - const hasTavilyKey = !!process.env['TAVILY_API_KEY']; - const hasGoogleKey = - !!process.env['GOOGLE_API_KEY'] && - !!process.env['GOOGLE_SEARCH_ENGINE_ID']; - - // Skip if no provider is configured - // Note: DashScope provider is automatically available for Qwen OAuth users, - // but we can't easily detect that in tests without actual OAuth credentials - if (!hasTavilyKey && !hasGoogleKey) { - console.warn( - 'Skipping web search test: No web search provider configured. ' + - 'Set TAVILY_API_KEY or GOOGLE_API_KEY+GOOGLE_SEARCH_ENGINE_ID environment variables.', - ); - return; - } - - const rig = new TestRig(); - // Configure web search in settings if provider keys are available - const webSearchSettings: Record = {}; - const providers: Array<{ - type: string; - apiKey?: string; - searchEngineId?: string; - }> = []; - - if (hasTavilyKey) { - providers.push({ type: 'tavily', apiKey: process.env['TAVILY_API_KEY'] }); - } - if (hasGoogleKey) { - providers.push({ - type: 'google', - apiKey: process.env['GOOGLE_API_KEY'], - searchEngineId: process.env['GOOGLE_SEARCH_ENGINE_ID'], - }); - } - - if (providers.length > 0) { - webSearchSettings.webSearch = { - provider: providers, - default: providers[0]?.type, - }; - } - - await rig.setup('should be able to search the web', { - settings: webSearchSettings, - }); - - let result; - try { - result = await rig.run(`what is the weather in London`); - } catch (error) { - // Network errors can occur in CI environments - if ( - error instanceof Error && - (error.message.includes('network') || error.message.includes('timeout')) - ) { - console.warn( - 'Skipping test due to network error:', - (error as Error).message, - ); - return; // Skip the test - } - throw error; // Re-throw if not a network error - } - - const foundToolCall = await rig.waitForToolCall('web_search'); - - // Add debugging information - if (!foundToolCall) { - const allTools = printDebugInfo(rig, result); - - // Check if the tool call failed due to network issues - const failedSearchCalls = allTools.filter( - (t) => t.toolRequest.name === 'web_search' && !t.toolRequest.success, - ); - if (failedSearchCalls.length > 0) { - console.warn( - 'web_search tool was called but failed, possibly due to network issues', - ); - console.warn( - 'Failed calls:', - failedSearchCalls.map((t) => t.toolRequest.args), - ); - return; // Skip the test if network issues - } - } - - expect(foundToolCall, 'Expected to find a call to web_search').toBeTruthy(); - - // Validate model output - will throw if no output, warn if missing expected content - const hasExpectedContent = validateModelOutput( - result, - ['weather', 'london'], - 'Web search test', - ); - - // If content was missing, log the search queries used - if (!hasExpectedContent) { - const searchCalls = rig - .readToolLogs() - .filter((t) => t.toolRequest.name === 'web_search'); - if (searchCalls.length > 0) { - console.warn( - 'Search queries used:', - searchCalls.map((t) => t.toolRequest.args), - ); - } - } - }); -}); diff --git a/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js b/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js index abb893b1c..5bcfd6d71 100644 --- a/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js +++ b/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js @@ -530,7 +530,6 @@ const TOOL_DISPLAY_NAME_BY_NAME = { skill: 'Skill', exit_plan_mode: 'ExitPlanMode', web_fetch: 'WebFetch', - web_search: 'WebSearch', list_directory: 'ListFiles', }; @@ -546,7 +545,6 @@ const TOOL_KIND_BY_NAME = { rename: 'move', grep_search: 'search', glob: 'search', - web_search: 'search', list_directory: 'search', run_shell_command: 'execute', bash: 'execute', diff --git a/integration-tests/sdk-typescript/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts index 5ea241db7..2ee6e8a8d 100644 --- a/integration-tests/sdk-typescript/permission-control.test.ts +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -905,7 +905,6 @@ describe('Permission Control (E2E)', () => { 'grep_search', 'glob', 'list_directory', - 'web_search', 'web_fetch', ]; diff --git a/package-lock.json b/package-lock.json index 98747357c..ecb84a6b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.15.0", + "version": "0.15.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.15.0", + "version": "0.15.2", "workspaces": [ "packages/*", "packages/channels/base", @@ -12842,7 +12842,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -16860,7 +16859,7 @@ }, "packages/channels/base": { "name": "@qwen-code/channel-base", - "version": "0.15.0", + "version": "0.15.2", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1" }, @@ -16870,7 +16869,7 @@ }, "packages/channels/dingtalk": { "name": "@qwen-code/channel-dingtalk", - "version": "0.15.0", + "version": "0.15.2", "dependencies": { "@qwen-code/channel-base": "file:../base", "dingtalk-stream-sdk-nodejs": "^2.0.4" @@ -16881,7 +16880,7 @@ }, "packages/channels/plugin-example": { "name": "@qwen-code/channel-plugin-example", - "version": "0.15.0", + "version": "0.15.2", "dependencies": { "@qwen-code/channel-base": "file:../base", "ws": "^8.18.0" @@ -16895,7 +16894,7 @@ }, "packages/channels/telegram": { "name": "@qwen-code/channel-telegram", - "version": "0.15.0", + "version": "0.15.2", "dependencies": { "@qwen-code/channel-base": "file:../base", "grammy": "^1.41.1", @@ -16908,7 +16907,7 @@ }, "packages/channels/weixin": { "name": "@qwen-code/channel-weixin", - "version": "0.15.0", + "version": "0.15.2", "dependencies": { "@qwen-code/channel-base": "file:../base" }, @@ -16918,7 +16917,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.15.0", + "version": "0.15.2", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", "@google/genai": "1.30.0", @@ -17577,7 +17576,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.15.0", + "version": "0.15.2", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -21021,7 +21020,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.15.0", + "version": "0.15.2", "license": "LICENSE", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", @@ -21268,7 +21267,7 @@ }, "packages/web-templates": { "name": "@qwen-code/web-templates", - "version": "0.15.0", + "version": "0.15.2", "devDependencies": { "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", @@ -21796,7 +21795,7 @@ }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.15.0", + "version": "0.15.2", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" diff --git a/package.json b/package.json index ad3892ac8..507ea5da2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.15.0", + "version": "0.15.2", "engines": { "node": ">=20.0.0" }, @@ -18,7 +18,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.15.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.15.2" }, "scripts": { "start": "cross-env node scripts/start.js", @@ -43,6 +43,7 @@ "test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests", "test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript", "test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript", + "test:sdk:python": "python3 -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q", "test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests cli", "test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests cli", "test:integration:interactive:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests interactive", @@ -53,9 +54,12 @@ "lint": "eslint . --ext .ts,.tsx && eslint integration-tests", "lint:fix": "eslint . --fix && eslint integration-tests --fix", "lint:ci": "eslint . --ext .ts,.tsx --max-warnings 0 && eslint integration-tests --max-warnings 0", + "lint:sdk:python": "python3 -m ruff check --config packages/sdk-python/pyproject.toml packages/sdk-python", "lint:all": "node scripts/lint.js", "format": "prettier --experimental-cli --write .", "typecheck": "npm run typecheck --workspaces --if-present", + "typecheck:sdk:python": "python3 -m mypy --config-file packages/sdk-python/pyproject.toml packages/sdk-python/src", + "smoke:sdk:python": "python3 packages/sdk-python/scripts/smoke_real.py", "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 build && npm run bundle", diff --git a/packages/channels/base/package.json b/packages/channels/base/package.json index 4d193ed0b..e76230105 100644 --- a/packages/channels/base/package.json +++ b/packages/channels/base/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-base", - "version": "0.15.0", + "version": "0.15.2", "description": "Base channel infrastructure for Qwen Code", "type": "module", "main": "dist/index.js", diff --git a/packages/channels/dingtalk/package.json b/packages/channels/dingtalk/package.json index c97719d1f..95ed8c944 100644 --- a/packages/channels/dingtalk/package.json +++ b/packages/channels/dingtalk/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-dingtalk", - "version": "0.15.0", + "version": "0.15.2", "description": "DingTalk channel adapter for Qwen Code", "type": "module", "main": "dist/index.js", diff --git a/packages/channels/plugin-example/package.json b/packages/channels/plugin-example/package.json index 6265a1d72..d681fbf72 100644 --- a/packages/channels/plugin-example/package.json +++ b/packages/channels/plugin-example/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-plugin-example", - "version": "0.15.0", + "version": "0.15.2", "private": true, "type": "module", "main": "dist/index.js", diff --git a/packages/channels/telegram/package.json b/packages/channels/telegram/package.json index 5016c57a0..e308b82a1 100644 --- a/packages/channels/telegram/package.json +++ b/packages/channels/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-telegram", - "version": "0.15.0", + "version": "0.15.2", "description": "Telegram channel adapter for Qwen Code", "type": "module", "main": "dist/index.js", diff --git a/packages/channels/weixin/package.json b/packages/channels/weixin/package.json index d980edc77..df43737b7 100644 --- a/packages/channels/weixin/package.json +++ b/packages/channels/weixin/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-weixin", - "version": "0.15.0", + "version": "0.15.2", "description": "WeChat (Weixin) channel adapter for Qwen Code", "type": "module", "main": "dist/index.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index cc6d5d578..d04a2d8d0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.15.0", + "version": "0.15.2", "description": "Qwen Code", "repository": { "type": "git", @@ -16,6 +16,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./export": { + "types": "./dist/src/export/index.d.ts", + "import": "./dist/src/export/index.js" } }, "scripts": { @@ -33,7 +37,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.15.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.15.2" }, "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 261035970..a5d92a303 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -45,7 +45,22 @@ vi.mock('@agentclientprotocol/sdk', () => ({ }, })), ndJsonStream: vi.fn().mockReturnValue({}), - RequestError: class RequestError extends Error {}, + RequestError: class RequestError extends Error { + static authRequired = vi + .fn() + .mockImplementation((data: unknown, msg: string) => { + const err = new Error(msg); + Object.assign(err, data); + return err; + }); + static invalidParams = vi + .fn() + .mockImplementation((data: unknown, msg: string) => { + const err = new Error(msg); + Object.assign(err, data); + return err; + }); + }, PROTOCOL_VERSION: '1.0.0', })); @@ -73,7 +88,9 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ clearCachedCredentialFile: vi.fn(), QwenOAuth2Event: {}, qwenOAuth2Events: { on: vi.fn(), off: vi.fn() }, - MCPServerConfig: {}, + MCPServerConfig: vi.fn().mockImplementation((...args: unknown[]) => ({ + _args: args, + })), SessionService: vi.fn(), tokenLimit: vi.fn(), SessionStartSource: { @@ -90,18 +107,31 @@ vi.mock('./authMethods.js', () => ({ buildAuthMethods: vi.fn() })); vi.mock('./service/filesystem.js', () => ({ AcpFileSystemService: vi.fn(), })); -vi.mock('../config/settings.js', () => ({ SettingScope: {} })); +vi.mock('../config/settings.js', () => ({ + SettingScope: {}, + loadSettings: vi.fn(), +})); vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn() })); vi.mock('./session/Session.js', () => ({ Session: vi.fn() })); vi.mock('../utils/acpModelUtils.js', () => ({ formatAcpModelId: vi.fn(), })); -import { runAcpAgent } from './acpAgent.js'; +import { + runAcpAgent, + toStdioServer, + toSseServer, + toHttpServer, +} from './acpAgent.js'; import type { Config } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../config/settings.js'; import type { CliArgs } from '../config/config.js'; -import { SessionEndReason } from '@qwen-code/qwen-code-core'; +import { SessionEndReason, MCPServerConfig } from '@qwen-code/qwen-code-core'; +import type { McpServer } from '@agentclientprotocol/sdk'; +import { AgentSideConnection } from '@agentclientprotocol/sdk'; +import { loadSettings } from '../config/settings.js'; +import { loadCliConfig } from '../config/config.js'; +import { Session } from './session/Session.js'; describe('runAcpAgent shutdown cleanup', () => { let processExitSpy: MockInstance; @@ -480,3 +510,387 @@ describe('runAcpAgent SessionEnd hooks', () => { expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1); }); }); + +// --------------------------------------------------------------------------- +// Unit tests for toStdioServer / toSseServer / toHttpServer helpers +// --------------------------------------------------------------------------- + +describe('toStdioServer', () => { + const stdioServer = { + name: 'my-stdio', + command: 'node', + args: ['server.js'], + env: [], + } as unknown as McpServer; + + const sseServer = { + type: 'sse', + name: 'my-sse', + url: 'http://localhost:3000/sse', + headers: [], + } as unknown as McpServer; + + it('returns the server when it is a stdio server', () => { + expect(toStdioServer(stdioServer)).toBe(stdioServer); + }); + + it('returns undefined for SSE server', () => { + expect(toStdioServer(sseServer)).toBeUndefined(); + }); + + it('returns undefined for HTTP server', () => { + const httpServer = { + type: 'http', + name: 'my-http', + url: 'http://localhost:3000/mcp', + headers: [], + } as unknown as McpServer; + expect(toStdioServer(httpServer)).toBeUndefined(); + }); +}); + +describe('toSseServer', () => { + it('returns the server when type is sse', () => { + const sseServer = { + type: 'sse', + name: 'my-sse', + url: 'http://localhost:3000/sse', + headers: [], + } as unknown as McpServer; + const result = toSseServer(sseServer); + expect(result).toBe(sseServer); + expect(result?.type).toBe('sse'); + }); + + it('returns undefined for stdio server', () => { + const stdioServer = { + name: 'my-stdio', + command: 'node', + args: [], + env: [], + } as unknown as McpServer; + expect(toSseServer(stdioServer)).toBeUndefined(); + }); + + it('returns undefined for http server', () => { + const httpServer = { + type: 'http', + name: 'my-http', + url: 'http://localhost:3000/mcp', + headers: [], + } as unknown as McpServer; + expect(toSseServer(httpServer)).toBeUndefined(); + }); +}); + +describe('toHttpServer', () => { + it('returns the server when type is http', () => { + const httpServer = { + type: 'http', + name: 'my-http', + url: 'http://localhost:3000/mcp', + headers: [], + } as unknown as McpServer; + const result = toHttpServer(httpServer); + expect(result).toBe(httpServer); + expect(result?.type).toBe('http'); + }); + + it('returns undefined for stdio server', () => { + const stdioServer = { + name: 'my-stdio', + command: 'node', + args: [], + env: [], + } as unknown as McpServer; + expect(toHttpServer(stdioServer)).toBeUndefined(); + }); + + it('returns undefined for sse server', () => { + const sseServer = { + type: 'sse', + name: 'my-sse', + url: 'http://localhost:3000/sse', + headers: [], + } as unknown as McpServer; + expect(toHttpServer(sseServer)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests for QwenAgent.initialize() mcpCapabilities + newSession SSE/HTTP +// --------------------------------------------------------------------------- + +describe('QwenAgent MCP SSE/HTTP support', () => { + // We need to capture the agent factory from AgentSideConnection constructor + let capturedAgentFactory: + | ((conn: AgentSideConnectionLike) => AgentLike) + | undefined; + + type AgentSideConnectionLike = { closed: Promise }; + type AgentLike = { + initialize: (args: Record) => Promise; + newSession: (args: Record) => Promise; + }; + + let mockConfig: Config; + let processExitSpy: MockInstance; + let stdinDestroySpy: MockInstance; + let stdoutDestroySpy: MockInstance; + + const mockArgv = {} as CliArgs; + + beforeEach(() => { + vi.clearAllMocks(); + mockConnectionState.reset(); + capturedAgentFactory = undefined; + + // Override AgentSideConnection mock to capture factory + vi.mocked(AgentSideConnection).mockImplementation((factory: unknown) => { + capturedAgentFactory = factory as typeof capturedAgentFactory; + return { + get closed() { + return mockConnectionState.promise; + }, + } as unknown as InstanceType; + }); + + mockConfig = { + initialize: vi.fn().mockResolvedValue(undefined), + getHookSystem: vi.fn().mockReturnValue(undefined), + getDisableAllHooks: vi.fn().mockReturnValue(false), + hasHooksForEvent: vi.fn().mockReturnValue(false), + getModel: vi.fn().mockReturnValue('test-model'), + getModelsConfig: vi.fn().mockReturnValue({ + getCurrentAuthType: vi.fn().mockReturnValue('api-key'), + }), + refreshAuth: vi.fn().mockResolvedValue(undefined), + } as unknown as Config; + + processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as unknown as typeof process.exit); + stdinDestroySpy = vi + .spyOn(process.stdin, 'destroy') + .mockImplementation(() => process.stdin); + stdoutDestroySpy = vi + .spyOn(process.stdout, 'destroy') + .mockImplementation(() => process.stdout); + }); + + afterEach(() => { + processExitSpy.mockRestore(); + stdinDestroySpy.mockRestore(); + stdoutDestroySpy.mockRestore(); + }); + + it('initialize response includes mcpCapabilities with sse and http', async () => { + const mockSettings = { + merged: { mcpServers: {} }, + } as unknown as LoadedSettings; + const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv); + + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const fakeConn = { + get closed() { + return mockConnectionState.promise; + }, + } as AgentSideConnectionLike; + + const agent = capturedAgentFactory!(fakeConn) as AgentLike; + const response = await agent.initialize({ clientCapabilities: {} }); + + expect(response).toMatchObject({ + agentCapabilities: { + mcpCapabilities: { + sse: true, + http: true, + }, + }, + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + function makeInnerConfig() { + return { + initialize: vi.fn().mockResolvedValue(undefined), + getModelsConfig: vi.fn().mockReturnValue({ + getCurrentAuthType: vi.fn().mockReturnValue('api-key'), + }), + refreshAuth: vi.fn().mockResolvedValue(undefined), + getModel: vi.fn().mockReturnValue('m'), + getContentGeneratorConfig: vi.fn().mockReturnValue({}), + getAvailableModels: vi.fn().mockReturnValue([]), + getModes: vi.fn().mockReturnValue([]), + getApprovalMode: vi.fn().mockReturnValue('default'), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getAuthType: vi.fn().mockReturnValue('api-key'), + getAllConfiguredModels: vi.fn().mockReturnValue([]), + getGeminiClient: vi.fn().mockReturnValue({ + isInitialized: vi.fn().mockReturnValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + }), + getFileSystemService: vi.fn().mockReturnValue(undefined), + setFileSystemService: vi.fn(), + getHookSystem: vi.fn().mockReturnValue(undefined), + getDisableAllHooks: vi.fn().mockReturnValue(true), + hasHooksForEvent: vi.fn().mockReturnValue(false), + }; + } + + function makeSessionSettings() { + return { + merged: { mcpServers: {} }, + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings; + } + + async function setupSessionMocks(sessionId: string) { + const innerConfig = makeInnerConfig(); + vi.mocked(loadSettings).mockReturnValue(makeSessionSettings()); + vi.mocked(loadCliConfig).mockResolvedValue( + innerConfig as unknown as Config, + ); + vi.mocked(Session).mockImplementation( + () => + ({ + getId: vi.fn().mockReturnValue(sessionId), + getConfig: vi.fn().mockReturnValue(innerConfig), + sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), + replayHistory: vi.fn().mockResolvedValue(undefined), + installRewriter: vi.fn(), + }) as unknown as InstanceType, + ); + return innerConfig; + } + + it('newSession with SSE MCP server creates MCPServerConfig with url', async () => { + await setupSessionMocks('session-sse'); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ + cwd: '/tmp', + mcpServers: [ + { + type: 'sse', + name: 'my-sse-server', + url: 'http://localhost:3001/sse', + headers: [{ name: 'Authorization', value: 'Bearer token123' }], + }, + ], + }); + + expect(MCPServerConfig).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + undefined, + 'http://localhost:3001/sse', + undefined, + { Authorization: 'Bearer token123' }, + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('newSession with HTTP MCP server creates MCPServerConfig with httpUrl', async () => { + await setupSessionMocks('session-http'); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ + cwd: '/tmp', + mcpServers: [ + { + type: 'http', + name: 'my-http-server', + url: 'http://localhost:3002/mcp', + headers: [], + }, + ], + }); + + expect(MCPServerConfig).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + undefined, + undefined, + 'http://localhost:3002/mcp', + undefined, + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('newSession with SSE MCP server and empty headers passes undefined for headers', async () => { + await setupSessionMocks('session-sse-noheaders'); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ + cwd: '/tmp', + mcpServers: [ + { + type: 'sse', + name: 'no-header-sse', + url: 'http://localhost:3003/sse', + headers: [], + }, + ], + }); + + expect(MCPServerConfig).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + undefined, + 'http://localhost:3003/sse', + undefined, + undefined, + ); + + mockConnectionState.resolve(); + await agentPromise; + }); +}); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index e40378bf9..41905996f 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -42,6 +42,8 @@ import type { LoadSessionRequest, LoadSessionResponse, McpServer, + McpServerHttp, + McpServerSse, McpServerStdio, NewSessionRequest, NewSessionResponse, @@ -162,13 +164,31 @@ export async function runAcpAgent( process.off('SIGINT', shutdownHandler); } -function toStdioServer(server: McpServer): McpServerStdio | undefined { +export function toStdioServer(server: McpServer): McpServerStdio | undefined { if ('command' in server && 'args' in server && 'env' in server) { return server as McpServerStdio; } return undefined; } +export function toSseServer( + server: McpServer, +): (McpServerSse & { type: 'sse' }) | undefined { + if ('type' in server && server.type === 'sse') { + return server as McpServerSse & { type: 'sse' }; + } + return undefined; +} + +export function toHttpServer( + server: McpServer, +): (McpServerHttp & { type: 'http' }) | undefined { + if ('type' in server && server.type === 'http') { + return server as McpServerHttp & { type: 'http' }; + } + return undefined; +} + class QwenAgent implements Agent { private sessions: Map = new Map(); private clientCapabilities: ClientCapabilities | undefined; @@ -204,6 +224,10 @@ class QwenAgent implements Agent { list: {}, resume: {}, }, + mcpCapabilities: { + sse: true, + http: true, + }, }, }; } @@ -499,18 +523,55 @@ class QwenAgent implements Agent { for (const server of mcpServers) { const stdioServer = toStdioServer(server); - if (!stdioServer) continue; - - const env: Record = {}; - for (const { name: envName, value } of stdioServer.env) { - env[envName] = value; + if (stdioServer) { + const env: Record = {}; + for (const { name: envName, value } of stdioServer.env) { + env[envName] = value; + } + mergedMcpServers[stdioServer.name] = new MCPServerConfig( + stdioServer.command, + stdioServer.args, + env, + cwd, + ); + continue; + } + + const sseServer = toSseServer(server); + if (sseServer) { + const headers: Record = {}; + for (const { name: headerName, value } of sseServer.headers) { + headers[headerName] = value; + } + mergedMcpServers[sseServer.name] = new MCPServerConfig( + undefined, + undefined, + undefined, + undefined, + sseServer.url, + undefined, + Object.keys(headers).length > 0 ? headers : undefined, + ); + continue; + } + + const httpServer = toHttpServer(server); + if (httpServer) { + const headers: Record = {}; + for (const { name: headerName, value } of httpServer.headers) { + headers[headerName] = value; + } + mergedMcpServers[httpServer.name] = new MCPServerConfig( + undefined, + undefined, + undefined, + undefined, + undefined, + httpServer.url, + Object.keys(headers).length > 0 ? headers : undefined, + ); + continue; } - mergedMcpServers[stdioServer.name] = new MCPServerConfig( - stdioServer.command, - stdioServer.args, - env, - cwd, - ); } const settings = { ...this.settings.merged, mcpServers: mergedMcpServers }; diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index ea8a44dd6..1f806b510 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -246,6 +246,43 @@ describe('Session', () => { }); }); + it('attaches available skills to available_commands_update metadata', async () => { + getAvailableCommandsSpy.mockResolvedValueOnce([ + { + name: 'init', + description: 'Initialize project context', + }, + ]); + mockConfig.getSkillManager = vi.fn().mockReturnValue({ + listSkills: vi + .fn() + .mockResolvedValue([ + { name: 'code-review-expert' }, + { name: 'verification-pack' }, + ]), + }); + + await session.sendAvailableCommandsUpdate(); + + expect(mockClient.sessionUpdate).toHaveBeenCalledTimes(1); + expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { + name: 'init', + description: 'Initialize project context', + input: null, + }, + ], + _meta: { + availableSkills: ['code-review-expert', 'verification-pack'], + }, + }, + }); + }); + it('swallows errors and does not throw', async () => { getAvailableCommandsSpy.mockRejectedValueOnce( new Error('Command discovery failed'), @@ -1082,6 +1119,125 @@ describe('Session', () => { }); }); + describe('tool call concurrency', () => { + it('runs multiple Agent tool calls concurrently (issue #2516)', async () => { + // Each Agent call has two controllable async boundaries: + // - `called` — resolves *when* the test code reaches `execute()` + // - `result` — the promise `execute()` returns, resolved by the + // test after observing both `called` signals. + // + // Under the old sequential for-loop, call-b's `execute()` would + // only run after call-a's `execute()` promise resolved — so the + // `await Promise.all([called-a, called-b])` below deadlocks and + // the test hits vitest's default per-test timeout. Under the + // concurrent implementation both `called` signals fire before + // either `result` is resolved. + type Deferred = { + promise: Promise; + resolve: (v: T) => void; + }; + const makeDeferred = (): Deferred => { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; + }; + + const called: Record> = { + 'call-a': makeDeferred(), + 'call-b': makeDeferred(), + }; + const result: Record> = { + 'call-a': makeDeferred(), + 'call-b': makeDeferred(), + }; + + const agentTool = { + name: core.ToolNames.AGENT, + kind: core.Kind.Think, + build: vi.fn().mockImplementation((args: Record) => { + const id = args['_test_id'] as string; + return { + params: args, + eventEmitter: undefined, + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + getDescription: vi.fn().mockReturnValue(`agent ${id}`), + toolLocations: vi.fn().mockReturnValue([]), + execute: vi.fn().mockImplementation(() => { + called[id].resolve(); + return result[id].promise; + }), + }; + }), + }; + + mockToolRegistry.getTool.mockImplementation((name: string) => + name === core.ToolNames.AGENT ? agentTool : undefined, + ); + mockConfig.getApprovalMode = vi + .fn() + .mockReturnValue(ApprovalMode.DEFAULT); + mockConfig.getPermissionManager = vi.fn().mockReturnValue(null); + + // Model returns two Agent calls, then an empty stream once results + // are fed back (to terminate the prompt loop). + const sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'call-a', + name: core.ToolNames.AGENT, + args: { _test_id: 'call-a', subagent_type: 'explore' }, + }, + { + id: 'call-b', + name: core.ToolNames.AGENT, + args: { _test_id: 'call-b', subagent_type: 'explore' }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()); + mockChat.sendMessageStream = sendMessageStream; + + const promptPromise = session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'spawn two agents' }], + }); + + // Wait until both `execute()` bodies have been entered. Sequential + // behaviour deadlocks here → vitest times out the test → failure. + await Promise.all([called['call-a'].promise, called['call-b'].promise]); + + // Resolve out of order to also verify that final part ordering + // follows the original functionCalls order, not resolution order. + result['call-b'].resolve({ llmContent: 'B-done', returnDisplay: 'B' }); + result['call-a'].resolve({ llmContent: 'A-done', returnDisplay: 'A' }); + + await promptPromise; + + // The second sendMessageStream invocation carries the tool responses + // that will be fed back to the model — assert their order matches + // the original function-call order (A before B). + expect(sendMessageStream).toHaveBeenCalledTimes(2); + const followUp = sendMessageStream.mock.calls[1][1] as { + message: Array<{ functionResponse?: { id?: string } }>; + }; + const ids = followUp.message + .filter((p) => p.functionResponse) + .map((p) => p.functionResponse?.id); + expect(ids).toEqual(['call-a', 'call-b']); + }); + }); + describe('system reminders', () => { // Captures the `message` parts fed into chat.sendMessageStream on the // first turn so individual tests can assert what the model saw. diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 9c4d0f999..f4426faab 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -522,17 +522,11 @@ export class Session implements SessionContext { } if (functionCalls.length > 0) { - const toolResponseParts: Part[] = []; - - for (const fc of functionCalls) { - const response = await this.runTool( - pendingSend.signal, - promptId, - fc, - ); - toolResponseParts.push(...response); - } - + const toolResponseParts = await this.runToolCalls( + pendingSend.signal, + promptId, + functionCalls, + ); nextMessage = { role: 'user', parts: toolResponseParts }; } } @@ -763,17 +757,11 @@ export class Session implements SessionContext { // Process tool calls from the follow-up message if (functionCalls.length > 0) { - const toolResponseParts: Part[] = []; - - for (const fc of functionCalls) { - const toolResponse = await this.runTool( - pendingSend.signal, - promptId, - fc, - ); - toolResponseParts.push(...toolResponse); - } - + const toolResponseParts = await this.runToolCalls( + pendingSend.signal, + promptId, + functionCalls, + ); nextMessage = { role: 'user', parts: toolResponseParts }; } } @@ -956,11 +944,11 @@ export class Session implements SessionContext { } if (functionCalls.length > 0) { - const toolResponseParts: Part[] = []; - for (const fc of functionCalls) { - const response = await this.runTool(ac.signal, promptId, fc); - toolResponseParts.push(...response); - } + const toolResponseParts = await this.runToolCalls( + ac.signal, + promptId, + functionCalls, + ); nextMessage = { role: 'user', parts: toolResponseParts }; } } @@ -997,9 +985,27 @@ export class Session implements SessionContext { }), ); + let availableSkills: string[] | undefined; + try { + const skillManager = this.config.getSkillManager(); + if (skillManager) { + const skills = await skillManager.listSkills(); + availableSkills = skills.map((skill) => skill.name); + } + } catch (error) { + debugLogger.error('Error loading available skills:', error); + } + const update: SessionUpdate = { sessionUpdate: 'available_commands_update', availableCommands, + ...(availableSkills + ? { + _meta: { + availableSkills, + }, + } + : {}), }; await this.sendUpdate(update); @@ -1103,6 +1109,79 @@ export class Session implements SessionContext { await this.sendUpdate(update); } + /** + * Execute a batch of model-returned tool calls, running Agent calls + * concurrently while keeping other tools sequential. + * + * Mirrors the partition logic in `coreToolScheduler.partitionToolCalls`: + * consecutive Agent calls form a parallel batch (they spawn independent + * sub-agents with no shared mutable state); any other tool forms its own + * sequential batch to preserve the implicit ordering the model may rely + * on. Response-part ordering matches the original `functionCalls` order. + */ + private async runToolCalls( + abortSignal: AbortSignal, + promptId: string, + functionCalls: FunctionCall[], + ): Promise { + type Batch = { concurrent: boolean; calls: FunctionCall[] }; + const batches: Batch[] = []; + for (const fc of functionCalls) { + const isAgent = fc.name === ToolNames.AGENT; + const last = batches[batches.length - 1]; + if (isAgent && last?.concurrent) { + last.calls.push(fc); + } else { + batches.push({ concurrent: isAgent, calls: [fc] }); + } + } + + // Bounded-concurrency runner: matches core's `runConcurrently` + // behaviour (`coreToolScheduler.ts:1506`), capped by + // `QWEN_CODE_MAX_TOOL_CONCURRENCY` (default 10). Results are returned + // in input order regardless of resolution order. + const runBounded = async (calls: FunctionCall[]): Promise => { + const parsed = parseInt( + process.env['QWEN_CODE_MAX_TOOL_CONCURRENCY'] || '', + 10, + ); + const maxConcurrency = + Number.isFinite(parsed) && parsed >= 1 ? parsed : 10; + const results: Part[][] = new Array(calls.length); + const executing = new Set>(); + for (let i = 0; i < calls.length; i++) { + const idx = i; + const p = this.runTool(abortSignal, promptId, calls[idx]) + .then((r) => { + results[idx] = r; + }) + .finally(() => { + executing.delete(p); + }); + executing.add(p); + if (executing.size >= maxConcurrency) { + await Promise.race(executing); + } + } + await Promise.all(executing); + return results; + }; + + const parts: Part[] = []; + for (const batch of batches) { + if (batch.concurrent && batch.calls.length > 1) { + const results = await runBounded(batch.calls); + for (const r of results) parts.push(...r); + } else { + for (const fc of batch.calls) { + const r = await this.runTool(abortSignal, promptId, fc); + parts.push(...r); + } + } + } + return parts; + } + /** * Assemble the per-turn system reminders the model needs to see at the * start of a user query or cron fire. Mirrors the subagent/plan/arena @@ -1247,14 +1326,19 @@ export class Session implements SessionContext { try { const invocation = tool.build(args); - if (isAgentTool && 'eventEmitter' in invocation) { - // Access eventEmitter from AgentTool invocation - const taskEventEmitter = ( - invocation as { - eventEmitter: AgentEventEmitter; - } - ).eventEmitter; - + // Production AgentTool always initializes `eventEmitter` on its + // invocation (`agent.ts:392`). Be defensive about the `undefined` + // case too so an incomplete/custom AgentTool invocation degrades + // gracefully (no sub-agent event forwarding) instead of throwing + // inside SubAgentTracker.setup — the `'eventEmitter' in invocation` + // key-presence check passed for `{ eventEmitter: undefined }` and + // the ensuing `eventEmitter.on(...)` blew up. + const taskEventEmitter = ( + invocation as { + eventEmitter?: AgentEventEmitter; + } + ).eventEmitter; + if (isAgentTool && taskEventEmitter) { // Extract subagent metadata from AgentTool call const parentToolCallId = callId; const subagentType = (args['subagent_type'] as string) ?? ''; diff --git a/packages/cli/src/commands/auth/handler.ts b/packages/cli/src/commands/auth/handler.ts index dd3421b6c..25a7d44fa 100644 --- a/packages/cli/src/commands/auth/handler.ts +++ b/packages/cli/src/commands/auth/handler.ts @@ -91,10 +91,6 @@ export async function handleQwenAuth( openaiLoggingDir: undefined, proxy: undefined, includeDirectories: undefined, - tavilyApiKey: undefined, - googleApiKey: undefined, - googleSearchEngineId: undefined, - webSearchDefault: undefined, screenReader: undefined, inputFormat: undefined, outputFormat: undefined, diff --git a/packages/cli/src/commands/extensions/examples/agent/agents/diary.md b/packages/cli/src/commands/extensions/examples/agent/agents/diary.md index 8c0c76a91..45eea1424 100644 --- a/packages/cli/src/commands/extensions/examples/agent/agents/diary.md +++ b/packages/cli/src/commands/extensions/examples/agent/agents/diary.md @@ -11,7 +11,6 @@ tools: - NotebookRead - WebFetch - TodoWrite - - WebSearch modelConfig: model: qwen3-coder-plus --- diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 01ad506b6..4c18efcc3 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1734,7 +1734,6 @@ describe('loadCliConfig with includeDirectories', () => { expect(config.getToolDiscoveryCommand()).toBeUndefined(); expect(config.getToolCallCommand()).toBeUndefined(); expect(config.getMcpServers()).toEqual({}); - expect(config.getWebSearchConfig()).toBeUndefined(); expect(config.isLspEnabled()).toBe(false); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d85d11941..304f878ac 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -67,7 +67,6 @@ export function isValidSessionId(value: string): boolean { } import { isWorkspaceTrusted } from './trustedFolders.js'; -import { buildWebSearchConfig } from './webSearch.js'; import { writeStderrLine } from '../utils/stdioHelpers.js'; const debugLogger = createDebugLogger('CONFIG'); @@ -138,10 +137,6 @@ export interface CliArgs { openaiLoggingDir: string | undefined; proxy: string | undefined; includeDirectories: string[] | undefined; - tavilyApiKey: string | undefined; - googleApiKey: string | undefined; - googleSearchEngineId: string | undefined; - webSearchDefault: string | undefined; screenReader: boolean | undefined; inputFormat?: string | undefined; outputFormat: string | undefined; @@ -431,23 +426,6 @@ export async function parseArguments(): Promise { type: 'string', description: 'OpenAI base URL (for custom endpoints)', }) - .option('tavily-api-key', { - type: 'string', - description: 'Tavily API key for web search', - }) - .option('google-api-key', { - type: 'string', - description: 'Google Custom Search API key', - }) - .option('google-search-engine-id', { - type: 'string', - description: 'Google Custom Search Engine ID', - }) - .option('web-search-default', { - type: 'string', - description: - 'Default web search provider (dashscope, tavily, google)', - }) .option('screen-reader', { type: 'boolean', description: 'Enable screen reader mode for accessibility.', @@ -1206,9 +1184,6 @@ export async function loadCliConfig( ? [] : (settings.security?.allowedHttpHookUrls ?? []), cliVersion: await getCliVersion(), - webSearch: bareMode - ? undefined - : buildWebSearchConfig(argv, settings, selectedAuthType), ideMode, chatCompression: settings.model?.chatCompression, folderTrust, diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 252db02c0..5d4363b13 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -28,7 +28,6 @@ describe('SettingsSchema', () => { 'mcp', 'security', 'advanced', - 'webSearch', ]; expectedSettings.forEach((setting) => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 8aa7517a6..9bfa13110 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1012,16 +1012,6 @@ const SETTINGS_SCHEMA = { 'Settings for clearing stale context after idle periods. Use -1 to disable a threshold.', showInDialog: false, properties: { - thinkingThresholdMinutes: { - type: 'number', - label: 'Thinking Idle Threshold (minutes)', - category: 'Context', - requiresRestart: false, - default: 5 as number, - description: - 'Minutes of inactivity before clearing old thinking blocks. Use -1 to disable.', - showInDialog: false, - }, toolResultsThresholdMinutes: { type: 'number', label: 'Tool Results Idle Threshold (minutes)', @@ -1590,37 +1580,9 @@ const SETTINGS_SCHEMA = { 'Config files remain at ~/.qwen. Env var QWEN_RUNTIME_DIR takes priority.', showInDialog: false, }, - tavilyApiKey: { - type: 'string', - label: 'Tavily API Key (Deprecated)', - category: 'Advanced', - requiresRestart: false, - default: undefined as string | undefined, - description: - '⚠️ DEPRECATED: Please use webSearch.provider configuration instead. Legacy API key for the Tavily API.', - showInDialog: false, - }, }, }, - webSearch: { - type: 'object', - label: 'Web Search', - category: 'Advanced', - requiresRestart: true, - default: undefined as - | { - provider: Array<{ - type: 'tavily' | 'google' | 'dashscope'; - apiKey?: string; - searchEngineId?: string; - }>; - default: string; - } - | undefined, - description: 'Configuration for web search providers.', - showInDialog: false, - }, agents: { type: 'object', label: 'Agents', diff --git a/packages/cli/src/config/webSearch.ts b/packages/cli/src/config/webSearch.ts deleted file mode 100644 index 4dc8adbbe..000000000 --- a/packages/cli/src/config/webSearch.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { WebSearchProviderConfig } from '@qwen-code/qwen-code-core'; -import type { Settings } from './settings.js'; - -/** - * CLI arguments related to web search configuration - */ -export interface WebSearchCliArgs { - tavilyApiKey?: string; - googleApiKey?: string; - googleSearchEngineId?: string; - webSearchDefault?: string; -} - -/** - * Web search configuration structure - */ -export interface WebSearchConfig { - provider: WebSearchProviderConfig[]; - default: string; -} - -/** - * Build webSearch configuration from multiple sources with priority: - * 1. settings.json (new format) - highest priority - * 2. Command line args + environment variables - * 3. Legacy tavilyApiKey (backward compatibility) - * - * @param argv - Command line arguments - * @param settings - User settings from settings.json - * @param authType - Authentication type (e.g., 'qwen-oauth') - * @returns WebSearch configuration or undefined if no providers available - */ -export function buildWebSearchConfig( - argv: WebSearchCliArgs, - settings: Settings, - _authType?: string, -): WebSearchConfig | undefined { - // Step 1: Collect providers from settings or command line/env - let providers: WebSearchProviderConfig[] = []; - let userDefault: string | undefined; - - if (settings.webSearch) { - // Use providers from settings.json - providers = [...settings.webSearch.provider]; - userDefault = settings.webSearch.default; - } else { - // Build providers from command line args and environment variables - const tavilyKey = - argv.tavilyApiKey || - settings.advanced?.tavilyApiKey || - process.env['TAVILY_API_KEY']; - if (tavilyKey) { - providers.push({ - type: 'tavily', - apiKey: tavilyKey, - } as WebSearchProviderConfig); - } - - const googleKey = argv.googleApiKey || process.env['GOOGLE_API_KEY']; - const googleEngineId = - argv.googleSearchEngineId || process.env['GOOGLE_SEARCH_ENGINE_ID']; - if (googleKey && googleEngineId) { - providers.push({ - type: 'google', - apiKey: googleKey, - searchEngineId: googleEngineId, - } as WebSearchProviderConfig); - } - } - - // Step 2: DashScope auto-injection for qwen-oauth was removed when the - // free tier was discontinued on 2026-04-15. Users who explicitly configure - // a dashscope provider in settings.json still get it (handled in Step 1). - - // Step 3: If no providers available, return undefined - if (providers.length === 0) { - return undefined; - } - - // Step 4: Determine default provider - // Priority: user explicit config > CLI arg > first available provider (tavily > google > dashscope) - const providerPriority: Array<'tavily' | 'google' | 'dashscope'> = [ - 'tavily', - 'google', - 'dashscope', - ]; - - // Determine default provider based on availability - let defaultProvider = userDefault || argv.webSearchDefault; - if (!defaultProvider) { - // Find first available provider by priority order - for (const providerType of providerPriority) { - if (providers.some((p) => p.type === providerType)) { - defaultProvider = providerType; - break; - } - } - // Fallback to first available provider if none found in priority list - if (!defaultProvider) { - defaultProvider = providers[0]?.type || 'dashscope'; - } - } - - return { - provider: providers, - default: defaultProvider, - }; -} diff --git a/packages/cli/src/export/index.ts b/packages/cli/src/export/index.ts new file mode 100644 index 000000000..04d1a7d4a --- /dev/null +++ b/packages/cli/src/export/index.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + collectSessionData, + generateExportFilename, + normalizeSessionData, + toHtml, + toJson, + toJsonl, + toMarkdown, + type ExportMessage, + type ExportSessionData, +} from '../ui/utils/export/index.js'; diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 1b7367984..6ecf2f0cc 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -595,10 +595,6 @@ describe('gemini.tsx main function kitty protocol', () => { openaiLoggingDir: undefined, proxy: undefined, includeDirectories: undefined, - tavilyApiKey: undefined, - googleApiKey: undefined, - googleSearchEngineId: undefined, - webSearchDefault: undefined, screenReader: undefined, inputFormat: undefined, outputFormat: undefined, @@ -675,6 +671,7 @@ describe('startInteractiveUI', () => { vi.mock('./ui/utils/kittyProtocolDetector.js', () => ({ detectAndEnableKittyProtocol: vi.fn(() => Promise.resolve(true)), + disableKittyProtocol: vi.fn(), })); vi.mock('./ui/utils/updateCheck.js', () => ({ diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 59f3b4b1d..e89c9384f 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -47,7 +47,10 @@ import { AgentViewProvider } from './ui/contexts/AgentViewContext.js'; import { BackgroundAgentViewProvider } from './ui/contexts/BackgroundAgentViewContext.js'; import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; import { themeManager, AUTO_THEME_NAME } from './ui/themes/theme-manager.js'; -import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js'; +import { + detectAndEnableKittyProtocol, + disableKittyProtocol, +} from './ui/utils/kittyProtocolDetector.js'; import { checkForUpdates } from './ui/utils/updateCheck.js'; import { cleanupCheckpoints, @@ -83,6 +86,7 @@ import { DualOutputContext } from './dualOutput/DualOutputContext.js'; import { RemoteInputWatcher } from './remoteInput/RemoteInputWatcher.js'; import { RemoteInputContext } from './remoteInput/RemoteInputContext.js'; import { installTerminalRedrawOptimizer } from './ui/utils/terminalRedrawOptimizer.js'; +import { installSynchronizedOutput } from './ui/utils/synchronizedOutput.js'; const debugLogger = createDebugLogger('STARTUP'); @@ -172,6 +176,10 @@ export async function startInteractiveUI( process.stdout.isTTY && !config.getScreenReader() ? installTerminalRedrawOptimizer(process.stdout) : () => {}; + const restoreSynchronizedOutput = + process.stdout.isTTY && !config.getScreenReader() + ? installSynchronizedOutput(process.stdout) + : () => {}; // Create dual output bridge if --json-fd or --json-file is specified. // Errors are caught so a bad fd/path degrades gracefully instead of @@ -292,7 +300,12 @@ export async function startInteractiveUI( registerCleanup(async () => { remoteInputWatcher?.shutdown(); await dualOutputBridge?.shutdown(); + // Explicitly disable the Kitty keyboard protocol before unmounting Ink so + // that the disable escape sequence is written while stdout is still fully + // operational, preventing garbled terminal output after the app exits. + disableKittyProtocol(); instance.unmount(); + restoreSynchronizedOutput(); restoreTerminalRedrawOptimizer(); }); } diff --git a/packages/cli/src/i18n/index.ts b/packages/cli/src/i18n/index.ts index b22c8c9b2..5fe71a8bc 100644 --- a/packages/cli/src/i18n/index.ts +++ b/packages/cli/src/i18n/index.ts @@ -57,15 +57,18 @@ const getLocalePath = ( export function detectSystemLanguage(): SupportedLanguage { const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG']; if (envLang) { + // Normalize POSIX locales (e.g. zh_TW.UTF-8 → zh-tw) before matching + const normalized = envLang.replace(/_/g, '-').toLowerCase(); for (const lang of SUPPORTED_LANGUAGES) { - if (envLang.startsWith(lang.code)) return lang.code; + if (normalized.startsWith(lang.code.toLowerCase())) return lang.code; } } try { const locale = Intl.DateTimeFormat().resolvedOptions().locale; + const normalized = locale.replace(/_/g, '-').toLowerCase(); for (const lang of SUPPORTED_LANGUAGES) { - if (locale.startsWith(lang.code)) return lang.code; + if (normalized.startsWith(lang.code.toLowerCase())) return lang.code; } } catch { // Fallback to default diff --git a/packages/cli/src/i18n/languages.ts b/packages/cli/src/i18n/languages.ts index d840f26ea..0f72c7c96 100644 --- a/packages/cli/src/i18n/languages.ts +++ b/packages/cli/src/i18n/languages.ts @@ -7,6 +7,7 @@ export type SupportedLanguage = | 'en' | 'zh' + | 'zh-TW' | 'ru' | 'de' | 'ja' @@ -32,6 +33,12 @@ export const SUPPORTED_LANGUAGES: readonly LanguageDefinition[] = [ fullName: 'English', nativeName: 'English', }, + { + code: 'zh-TW', + id: 'zh-TW', + fullName: 'Traditional Chinese', + nativeName: '繁體中文', + }, { code: 'zh', id: 'zh-CN', @@ -75,7 +82,8 @@ export const SUPPORTED_LANGUAGES: readonly LanguageDefinition[] = [ * Used for LLM output language instructions. */ export function getLanguageNameFromLocale(locale: SupportedLanguage): string { - const lang = SUPPORTED_LANGUAGES.find((l) => l.code === locale); + const lower = locale.toLowerCase(); + const lang = SUPPORTED_LANGUAGES.find((l) => l.code.toLowerCase() === lower); return lang?.fullName || 'English'; } diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 04b195449..c2a427bdb 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -575,6 +575,8 @@ export default { 'Updates all extensions or a named extension to the latest version.': 'Updates all extensions or a named extension to the latest version.', 'Update all extensions.': 'Update all extensions.', + 'The name of the extension to update.': + 'The name of the extension to update.', 'Either an extension name or --all must be provided': 'Either an extension name or --all must be provided', 'Lists installed extensions.': 'Lists installed extensions.', @@ -726,6 +728,7 @@ export default { 'User Settings': 'User Settings', 'System Settings': 'System Settings', Extensions: 'Extensions', + 'Session (temporary)': 'Session (temporary)', // Hooks - Status '✓ Enabled': '✓ Enabled', '✗ Disabled': '✗ Disabled', @@ -1896,6 +1899,8 @@ export default { // Coding Plan Authentication // ============================================================================ 'API key cannot be empty.': 'API key cannot be empty.', + 'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.': + 'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.', '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.': @@ -1973,6 +1978,8 @@ export default { 'Show context window usage breakdown.', 'Run /context detail for per-item breakdown.': 'Run /context detail for per-item breakdown.', + 'Show context window usage breakdown. Use "/context detail" for per-item breakdown.': + 'Show context window usage breakdown. Use "/context detail" for per-item breakdown.', 'body loaded': 'body loaded', memory: 'memory', '{{region}} configuration updated successfully.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js new file mode 100644 index 000000000..9460ba71d --- /dev/null +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -0,0 +1,1676 @@ +/** + * @license + * Copyright 2025 Qwen team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Traditional Chinese (zh-TW) translations for Qwen Code CLI +// Auto-generated: structure from en.js, values from zh-TW (manual) or opencc(zh.js s2t) + +export default { + '↑ to manage attachments': '↑ 管理附件', + '← → select, Delete to remove, ↓ to exit': '← → 選擇,Delete 刪除,↓ 退出', + 'Attachments: ': '附件:', + 'Basics:': '基礎功能:', + 'Add context': '添加上下文', + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': + '使用 {{symbol}} 指定文件作爲上下文(例如,{{example}}),用於定位特定文件或文件夾', + '@': '@', + '@src/myFile.ts': '@src/myFile.ts', + 'Shell mode': 'Shell 模式', + 'YOLO mode': 'YOLO 模式', + 'plan mode': '規劃模式', + 'auto-accept edits': '自動接受編輯', + 'Accepting edits': '接受編輯', + '(shift + tab to cycle)': '(shift + tab 切換)', + '(tab to cycle)': '(按 tab 切換)', + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': + '通過 {{symbol}} 執行 shell 命令(例如,{{example1}})或使用自然語言(例如,{{example2}})', + '!': '!', + '!npm run start': '!npm run start', + 'start server': 'start server', + 'Commands:': '命令:', + 'shell command': 'shell 命令', + 'Model Context Protocol command (from external servers)': + '模型上下文協議命令(來自外部服務器)', + 'Keyboard Shortcuts:': '鍵盤快捷鍵:', + 'Toggle this help display': '切換此幫助顯示', + 'Toggle shell mode': '切換命令行模式', + 'Open command menu': '打開命令菜單', + 'Add file context': '添加文件上下文', + 'Accept suggestion / Autocomplete': '接受建議 / 自動補全', + 'Reverse search history': '反向搜索歷史', + 'Press ? again to close': '再次按 ? 關閉', + 'for shell mode': '命令行模式', + 'for commands': '命令菜單', + 'for file paths': '文件路徑', + 'to clear input': '清空輸入', + 'to cycle approvals': '切換審批模式', + 'to quit': '退出', + 'for newline': '換行', + 'to clear screen': '清屏', + 'to search history': '搜索歷史', + 'to paste images': '粘貼圖片', + 'for external editor': '外部編輯器', + 'to toggle compact mode': '切換緊湊模式', + 'Jump through words in the input': '在輸入中按單詞跳轉', + 'Close dialogs, cancel requests, or quit application': + '關閉對話框、取消請求或退出應用程序', + 'New line': '換行', + 'New line (Alt+Enter works for certain linux distros)': + '換行(某些 Linux 發行版支持 Alt+Enter)', + 'Clear the screen': '清屏', + 'Open input in external editor': '在外部編輯器中打開輸入', + 'Send message': '發送消息', + 'Initializing...': '正在初始化...', + 'Connecting to MCP servers... ({{connected}}/{{total}})': + '正在連接到 MCP 服務器... ({{connected}}/{{total}})', + 'Type your message or @path/to/file': '輸入您的消息或 @ 文件路徑', + '? for shortcuts': '按 ? 查看快捷鍵', + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": + "按 'i' 進入插入模式,按 'Esc' 進入普通模式", + 'Cancel operation / Clear input (double press)': + '取消操作 / 清空輸入(雙擊)', + 'Cycle approval modes': '循環切換審批模式', + 'Cycle through your prompt history': '循環瀏覽提示歷史', + 'For a full list of shortcuts, see {{docPath}}': + '完整快捷鍵列表,請參閱 {{docPath}}', + 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', + 'for help on Qwen Code': '獲取 Qwen Code 幫助', + 'show version info': '顯示版本信息', + 'submit a bug report': '提交錯誤報告', + 'About Qwen Code': '關於 Qwen Code', + Status: '狀態', + 'Qwen Code': 'Qwen Code', + Runtime: '運行環境', + OS: '操作系統', + Auth: '認證', + 'CLI Version': 'CLI 版本', + 'Git Commit': 'Git 提交', + Model: '模型', + 'Fast Model': '快速模型', + Sandbox: '沙箱', + 'OS Platform': '操作系統平臺', + 'OS Arch': '操作系統架構', + 'OS Release': '操作系統版本', + 'Node.js Version': 'Node.js 版本', + 'NPM Version': 'NPM 版本', + 'Session ID': '會話 ID', + 'Auth Method': '認證方式', + 'Base URL': '基礎 URL', + Proxy: '代理', + 'Memory Usage': '內存使用', + 'IDE Client': 'IDE 客戶端', + 'Analyzes the project and creates a tailored QWEN.md file.': + '分析項目並創建定製的 QWEN.md 文件', + 'List available Qwen Code tools. Usage: /tools [desc]': + '列出可用的 Qwen Code 工具。用法:/tools [desc]', + 'List available skills.': '列出可用技能。', + 'Available Qwen Code CLI tools:': '可用的 Qwen Code CLI 工具:', + 'No tools available': '沒有可用工具', + 'View or change the approval mode for tool usage': + '查看或更改工具使用的審批模式', + 'Invalid approval mode "{{arg}}". Valid modes: {{modes}}': + '無效的審批模式 "{{arg}}"。有效模式:{{modes}}', + 'Approval mode set to "{{mode}}"': '審批模式已設置爲 "{{mode}}"', + 'View or change the language setting': '查看或更改語言設置', + 'change the theme': '更改主題', + 'Select Theme': '選擇主題', + Preview: '預覽', + '(Use Enter to select, Tab to configure scope)': + '(使用 Enter 選擇,Tab 配置作用域)', + '(Use Enter to apply scope, Tab to go back)': + '(使用 Enter 應用作用域,Tab 返回)', + 'Theme configuration unavailable due to NO_COLOR env variable.': + '由於 NO_COLOR 環境變量,主題配置不可用。', + 'Theme "{{themeName}}" not found.': '未找到主題 "{{themeName}}"。', + 'Theme "{{themeName}}" not found in selected scope.': + '在所選作用域中未找到主題 "{{themeName}}"。', + 'Clear conversation history and free up context': '清除對話歷史並釋放上下文', + 'Compresses the context by replacing it with a summary.': + '通過摘要替換來壓縮上下文', + 'open full Qwen Code documentation in your browser': + '在瀏覽器中打開完整的 Qwen Code 文檔', + 'Configuration not available.': '配置不可用', + 'change the auth method': '更改認證方法', + 'Configure authentication information for login': '配置登錄認證信息', + 'Copy the last result or code snippet to clipboard': + '將最後的結果或代碼片段複製到剪貼板', + 'Manage subagents for specialized task delegation.': + '管理用於專門任務委派的子智能體', + 'Manage existing subagents (view, edit, delete).': + '管理現有子智能體(查看、編輯、刪除)', + 'Create a new subagent with guided setup.': '通過引導式設置創建新的子智能體', + Agents: '智能體', + 'Choose Action': '選擇操作', + 'Edit {{name}}': '編輯 {{name}}', + 'Edit Tools: {{name}}': '編輯工具: {{name}}', + 'Edit Color: {{name}}': '編輯顏色: {{name}}', + 'Delete {{name}}': '刪除 {{name}}', + 'Unknown Step': '未知步驟', + 'Esc to close': '按 Esc 關閉', + 'Enter to select, ↑↓ to navigate, Esc to close': + 'Enter 選擇,↑↓ 導航,Esc 關閉', + 'Esc to go back': '按 Esc 返回', + 'Enter to confirm, Esc to cancel': 'Enter 確認,Esc 取消', + 'Enter to select, ↑↓ to navigate, Esc to go back': + 'Enter 選擇,↑↓ 導航,Esc 返回', + 'Enter to submit, Esc to go back': 'Enter 提交,Esc 返回', + 'Invalid step: {{step}}': '無效步驟: {{step}}', + 'No subagents found.': '未找到子智能體。', + "Use '/agents create' to create your first subagent.": + "使用 '/agents create' 創建您的第一個子智能體。", + '(built-in)': '(內置)', + '(overridden by project level agent)': '(已被項目級智能體覆蓋)', + 'Project Level ({{path}})': '項目級 ({{path}})', + 'User Level ({{path}})': '用戶級 ({{path}})', + 'Built-in Agents': '內置智能體', + 'Extension Agents': '擴展智能體', + 'Using: {{count}} agents': '使用中: {{count}} 個智能體', + 'View Agent': '查看智能體', + 'Edit Agent': '編輯智能體', + 'Delete Agent': '刪除智能體', + Back: '返回', + 'No agent selected': '未選擇智能體', + 'File Path: ': '文件路徑: ', + 'Tools: ': '工具: ', + 'Color: ': '顏色: ', + 'Description:': '描述:', + 'System Prompt:': '系統提示:', + 'Open in editor': '在編輯器中打開', + 'Edit tools': '編輯工具', + 'Edit color': '編輯顏色', + '❌ Error:': '❌ 錯誤:', + 'Are you sure you want to delete agent "{{name}}"?': + '您確定要刪除智能體 "{{name}}" 嗎?', + 'Project Level (.qwen/agents/)': '項目級 (.qwen/agents/)', + 'User Level (~/.qwen/agents/)': '用戶級 (~/.qwen/agents/)', + '✅ Subagent Created Successfully!': '✅ 子智能體創建成功!', + 'Subagent "{{name}}" has been saved to {{level}} level.': + '子智能體 "{{name}}" 已保存到 {{level}} 級別。', + 'Name: ': '名稱: ', + 'Location: ': '位置: ', + '❌ Error saving subagent:': '❌ 保存子智能體時出錯:', + 'Warnings:': '警告:', + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': + '名稱 "{{name}}" 在 {{level}} 級別已存在 - 將覆蓋現有子智能體', + 'Name "{{name}}" exists at user level - project level will take precedence': + '名稱 "{{name}}" 在用戶級別存在 - 項目級別將優先', + 'Name "{{name}}" exists at project level - existing subagent will take precedence': + '名稱 "{{name}}" 在項目級別存在 - 現有子智能體將優先', + 'Description is over {{length}} characters': '描述超過 {{length}} 個字符', + 'System prompt is over {{length}} characters': + '系統提示超過 {{length}} 個字符', + 'Step {{n}}: Choose Location': '步驟 {{n}}: 選擇位置', + 'Step {{n}}: Choose Generation Method': '步驟 {{n}}: 選擇生成方式', + 'Generate with Qwen Code (Recommended)': '使用 Qwen Code 生成(推薦)', + 'Manual Creation': '手動創建', + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': + '描述此子智能體應該做什麼以及何時使用它。(爲了獲得最佳效果,請全面描述)', + 'e.g., Expert code reviewer that reviews code based on best practices...': + '例如:專業的代碼審查員,根據最佳實踐審查代碼...', + 'Generating subagent configuration...': '正在生成子智能體配置...', + 'Failed to generate subagent: {{error}}': '生成子智能體失敗: {{error}}', + 'Step {{n}}: Describe Your Subagent': '步驟 {{n}}: 描述您的子智能體', + 'Step {{n}}: Enter Subagent Name': '步驟 {{n}}: 輸入子智能體名稱', + 'Step {{n}}: Enter System Prompt': '步驟 {{n}}: 輸入系統提示', + 'Step {{n}}: Enter Description': '步驟 {{n}}: 輸入描述', + 'Step {{n}}: Select Tools': '步驟 {{n}}: 選擇工具', + 'All Tools (Default)': '所有工具(默認)', + 'All Tools': '所有工具', + 'Read-only Tools': '只讀工具', + 'Read & Edit Tools': '讀取和編輯工具', + 'Read & Edit & Execution Tools': '讀取、編輯和執行工具', + 'All tools selected, including MCP tools': '已選擇所有工具,包括 MCP 工具', + 'Selected tools:': '已選擇的工具:', + 'Read-only tools:': '只讀工具:', + 'Edit tools:': '編輯工具:', + 'Execution tools:': '執行工具:', + 'Step {{n}}: Choose Background Color': '步驟 {{n}}: 選擇背景顏色', + 'Step {{n}}: Confirm and Save': '步驟 {{n}}: 確認並保存', + 'Esc to cancel': '按 Esc 取消', + 'Press Enter to save, e to save and edit, Esc to go back': + '按 Enter 保存,e 保存並編輯,Esc 返回', + 'Press Enter to continue, {{navigation}}Esc to {{action}}': + '按 Enter 繼續,{{navigation}}Esc {{action}}', + cancel: '取消', + 'go back': '返回', + '↑↓ to navigate, ': '↑↓ 導航,', + 'Enter a clear, unique name for this subagent.': + '爲此子智能體輸入一個清晰、唯一的名稱。', + 'e.g., Code Reviewer': '例如:代碼審查員', + 'Name cannot be empty.': '名稱不能爲空。', + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": + '編寫定義此子智能體行爲的系統提示。爲了獲得最佳效果,請全面描述。', + 'e.g., You are an expert code reviewer...': + '例如:您是一位專業的代碼審查員...', + 'System prompt cannot be empty.': '系統提示不能爲空。', + 'Describe when and how this subagent should be used.': + '描述何時以及如何使用此子智能體。', + 'e.g., Reviews code for best practices and potential bugs.': + '例如:審查代碼以查找最佳實踐和潛在錯誤。', + 'Description cannot be empty.': '描述不能爲空。', + 'Failed to launch editor: {{error}}': '啓動編輯器失敗: {{error}}', + 'Failed to save and edit subagent: {{error}}': + '保存並編輯子智能體失敗: {{error}}', + 'Manage Extensions': '管理擴展', + 'Extension Details': '擴展詳情', + 'View Extension': '查看擴展', + 'Update Extension': '更新擴展', + 'Disable Extension': '禁用擴展', + 'Enable Extension': '啓用擴展', + 'Uninstall Extension': '卸載擴展', + 'Select Scope': '選擇作用域', + 'User Scope': '用戶作用域', + 'Workspace Scope': '工作區作用域', + 'No extensions found.': '未找到擴展。', + Active: '已啓用', + Disabled: '已禁用', + 'Update available': '有可用更新', + 'Up to date': '已是最新', + 'Checking...': '檢查中...', + 'Updating...': '更新中...', + Unknown: '未知', + Error: '錯誤', + 'Version:': '版本:', + 'Status:': '狀態:', + 'Are you sure you want to uninstall extension "{{name}}"?': + '確定要卸載擴展 "{{name}}" 嗎?', + 'This action cannot be undone.': '此操作無法撤銷。', + 'Extension "{{name}}" disabled successfully.': '擴展 "{{name}}" 禁用成功。', + 'Extension "{{name}}" enabled successfully.': '擴展 "{{name}}" 啓用成功。', + 'Extension "{{name}}" updated successfully.': '擴展 "{{name}}" 更新成功。', + 'Failed to update extension "{{name}}": {{error}}': + '更新擴展 "{{name}}" 失敗:{{error}}', + 'Select the scope for this action:': '選擇此操作的作用域:', + 'User - Applies to all projects': '用戶 - 應用於所有項目', + 'Workspace - Applies to current project only': '工作區 - 僅應用於當前項目', + 'Name:': '名稱:', + 'MCP Servers:': 'MCP 服務器:', + 'Settings:': '設置:', + active: '已啓用', + disabled: '已禁用', + 'View Details': '查看詳情', + 'Update failed:': '更新失敗:', + 'Updating {{name}}...': '正在更新 {{name}}...', + 'Update complete!': '更新完成!', + 'User (global)': '用戶(全局)', + 'Workspace (project-specific)': '工作區(項目特定)', + 'Disable "{{name}}" - Select Scope': '禁用 "{{name}}" - 選擇作用域', + 'Enable "{{name}}" - Select Scope': '啓用 "{{name}}" - 選擇作用域', + 'No extension selected': '未選擇擴展', + 'Press Y/Enter to confirm, N/Esc to cancel': '按 Y/Enter 確認,N/Esc 取消', + 'Y/Enter to confirm, N/Esc to cancel': 'Y/Enter 確認,N/Esc 取消', + '{{count}} extensions installed': '已安裝 {{count}} 個擴展', + "Use '/extensions install' to install your first extension.": + "使用 '/extensions install' 安裝您的第一個擴展。", + 'up to date': '已是最新', + 'update available': '有可用更新', + 'checking...': '檢查中...', + 'not updatable': '不可更新', + error: '錯誤', + 'View and edit Qwen Code settings': '查看和編輯 Qwen Code 設置', + Settings: '設置', + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': + '要查看更改,必須重啓 Qwen Code。按 r 退出並立即應用更改。', + 'The command "/{{command}}" is not supported in non-interactive mode.': + '不支持在非交互模式下使用命令 "/{{command}}"。', + 'Vim Mode': 'Vim 模式', + 'Disable Auto Update': '禁用自動更新', + 'Attribution: commit': '署名:提交', + 'Terminal Bell Notification': '終端響鈴通知', + 'Enable Usage Statistics': '啓用使用統計', + Theme: '主題', + 'Preferred Editor': '首選編輯器', + 'Auto-connect to IDE': '自動連接到 IDE', + 'Enable Prompt Completion': '啓用提示補全', + 'Debug Keystroke Logging': '調試按鍵記錄', + 'Language: UI': '語言:界面', + 'Language: Model': '語言:模型', + 'Output Format': '輸出格式', + 'Hide Window Title': '隱藏窗口標題', + 'Show Status in Title': '在標題中顯示狀態', + 'Hide Tips': '隱藏提示', + 'Show Line Numbers in Code': '在代碼中顯示行號', + 'Show Citations': '顯示引用', + 'Custom Witty Phrases': '自定義詼諧短語', + 'Show Welcome Back Dialog': '顯示歡迎回來對話框', + 'Enable User Feedback': '啓用用戶反饋', + 'How is Qwen doing this session? (optional)': 'Qwen 這次表現如何?(可選)', + Bad: '不滿意', + Fine: '還行', + Good: '滿意', + Dismiss: '忽略', + 'Not Sure Yet': '暫不評價', + 'Any other key': '任意其他鍵', + 'Disable Loading Phrases': '禁用加載短語', + 'Screen Reader Mode': '屏幕閱讀器模式', + 'IDE Mode': 'IDE 模式', + 'Max Session Turns': '最大會話輪次', + 'Skip Next Speaker Check': '跳過下一個說話者檢查', + 'Skip Loop Detection': '跳過循環檢測', + 'Skip Startup Context': '跳過啓動上下文', + 'Enable OpenAI Logging': '啓用 OpenAI 日誌', + 'OpenAI Logging Directory': 'OpenAI 日誌目錄', + Timeout: '超時', + 'Max Retries': '最大重試次數', + 'Disable Cache Control': '禁用緩存控制', + 'Memory Discovery Max Dirs': '內存發現最大目錄數', + 'Load Memory From Include Directories': '從包含目錄加載內存', + 'Respect .gitignore': '遵守 .gitignore', + 'Respect .qwenignore': '遵守 .qwenignore', + 'Enable Recursive File Search': '啓用遞歸文件搜索', + 'Disable Fuzzy Search': '禁用模糊搜索', + 'Interactive Shell (PTY)': '交互式 Shell (PTY)', + 'Show Color': '顯示顏色', + 'Auto Accept': '自動接受', + 'Use Ripgrep': '使用 Ripgrep', + 'Use Builtin Ripgrep': '使用內置 Ripgrep', + 'Enable Tool Output Truncation': '啓用工具輸出截斷', + 'Tool Output Truncation Threshold': '工具輸出截斷閾值', + 'Tool Output Truncation Lines': '工具輸出截斷行數', + 'Folder Trust': '文件夾信任', + 'Vision Model Preview': '視覺模型預覽', + 'Tool Schema Compliance': '工具 Schema 兼容性', + 'Auto (detect from system)': '自動(從系統檢測)', + 'Auto (detect terminal theme)': '自動(檢測終端主題)', + Auto: '自動', + Text: '文本', + JSON: 'JSON', + Plan: '規劃', + Default: '默認', + 'Auto Edit': '自動編輯', + YOLO: 'YOLO', + 'toggle vim mode on/off': '切換 vim 模式開關', + 'check session stats. Usage: /stats [model|tools]': + '檢查會話統計信息。用法:/stats [model|tools]', + 'Show model-specific usage statistics.': '顯示模型相關的使用統計信息', + 'Show tool-specific usage statistics.': '顯示工具相關的使用統計信息', + 'exit the cli': '退出命令行界面', + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + '打開 MCP 管理對話框,或在支持 OAuth 的服務器上進行身份驗證', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': + '列出已配置的 MCP 服務器和工具,或使用支持 OAuth 的服務器進行身份驗證', + 'Manage workspace directories': '管理工作區目錄', + 'Add directories to the workspace. Use comma to separate multiple paths': + '將目錄添加到工作區。使用逗號分隔多個路徑', + 'Show all directories in the workspace': '顯示工作區中的所有目錄', + 'set external editor preference': '設置外部編輯器首選項', + 'Select Editor': '選擇編輯器', + 'Editor Preference': '編輯器首選項', + 'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.': + '當前支持以下編輯器。請注意,某些編輯器無法在沙箱模式下使用。', + 'Your preferred editor is:': '您的首選編輯器是:', + 'Manage extensions': '管理擴展', + 'Manage installed extensions': '管理已安裝的擴展', + 'List active extensions': '列出活動擴展', + 'Update extensions. Usage: update |--all': + '更新擴展。用法:update |--all', + 'Disable an extension': '禁用擴展', + 'Enable an extension': '啓用擴展', + 'Install an extension from a git repo or local path': + '從 Git 倉庫或本地路徑安裝擴展', + 'Uninstall an extension': '卸載擴展', + 'No extensions installed.': '未安裝擴展。', + 'Usage: /extensions update |--all': + '用法:/extensions update <擴展名>|--all', + 'Extension "{{name}}" not found.': '未找到擴展 "{{name}}"。', + 'No extensions to update.': '沒有可更新的擴展。', + 'Usage: /extensions install ': '用法:/extensions install <來源>', + 'Installing extension from "{{source}}"...': + '正在從 "{{source}}" 安裝擴展...', + 'Extension "{{name}}" installed successfully.': '擴展 "{{name}}" 安裝成功。', + 'Failed to install extension from "{{source}}": {{error}}': + '從 "{{source}}" 安裝擴展失敗:{{error}}', + 'Usage: /extensions uninstall ': + '用法:/extensions uninstall <擴展名>', + 'Uninstalling extension "{{name}}"...': '正在卸載擴展 "{{name}}"...', + 'Extension "{{name}}" uninstalled successfully.': + '擴展 "{{name}}" 卸載成功。', + 'Failed to uninstall extension "{{name}}": {{error}}': + '卸載擴展 "{{name}}" 失敗:{{error}}', + 'Usage: /extensions {{command}} [--scope=]': + '用法:/extensions {{command}} <擴展> [--scope=]', + 'Unsupported scope "{{scope}}", should be one of "user" or "workspace"': + '不支持的作用域 "{{scope}}",應爲 "user" 或 "workspace"', + 'Extension "{{name}}" disabled for scope "{{scope}}"': + '擴展 "{{name}}" 已在作用域 "{{scope}}" 中禁用', + 'Extension "{{name}}" enabled for scope "{{scope}}"': + '擴展 "{{name}}" 已在作用域 "{{scope}}" 中啓用', + 'Do you want to continue? [Y/n]: ': '是否繼續?[Y/n]:', + 'Do you want to continue?': '是否繼續?', + 'Installing extension "{{name}}".': '正在安裝擴展 "{{name}}"。', + '**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**': + '**擴展可能會引入意外行爲。請確保您已調查過擴展源並信任作者。**', + 'This extension will run the following MCP servers:': + '此擴展將運行以下 MCP 服務器:', + local: '本地', + remote: '遠程', + 'This extension will add the following commands: {{commands}}.': + '此擴展將添加以下命令:{{commands}}。', + 'This extension will append info to your QWEN.md context using {{fileName}}': + '此擴展將使用 {{fileName}} 向您的 QWEN.md 上下文追加信息', + 'This extension will exclude the following core tools: {{tools}}': + '此擴展將排除以下核心工具:{{tools}}', + 'This extension will install the following skills:': '此擴展將安裝以下技能:', + 'This extension will install the following subagents:': + '此擴展將安裝以下子智能體:', + 'Installation cancelled for "{{name}}".': '已取消安裝 "{{name}}"。', + 'You are installing an extension from {{originSource}}. Some features may not work perfectly with Qwen Code.': + '您正在安裝來自 {{originSource}} 的擴展。某些功能可能無法完美兼容 Qwen Code。', + '--ref and --auto-update are not applicable for marketplace extensions.': + '--ref 和 --auto-update 不適用於市場擴展。', + 'Extension "{{name}}" installed successfully and enabled.': + '擴展 "{{name}}" 安裝成功並已啓用。', + 'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).': + '從 Git 倉庫 URL、本地路徑或 Claude 市場(marketplace-url:plugin-name)安裝擴展。', + 'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.': + '要安裝的擴展的 GitHub URL、本地路徑或市場源(marketplace-url:plugin-name)。', + 'The git ref to install from.': '要安裝的 Git 引用。', + 'Enable auto-update for this extension.': '爲此擴展啓用自動更新。', + 'Enable pre-release versions for this extension.': '爲此擴展啓用預發佈版本。', + 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.': + '確認安裝擴展的安全風險並跳過確認提示。', + 'The source argument must be provided.': '必須提供來源參數。', + 'Extension "{{name}}" successfully uninstalled.': + '擴展 "{{name}}" 卸載成功。', + 'Uninstalls an extension.': '卸載擴展。', + 'The name or source path of the extension to uninstall.': + '要卸載的擴展的名稱或源路徑。', + 'Please include the name of the extension to uninstall as a positional argument.': + '請將要卸載的擴展名稱作爲位置參數。', + 'Enables an extension.': '啓用擴展。', + 'The name of the extension to enable.': '要啓用的擴展名稱。', + 'The scope to enable the extenison in. If not set, will be enabled in all scopes.': + '啓用擴展的作用域。如果未設置,將在所有作用域中啓用。', + 'Extension "{{name}}" successfully enabled for scope "{{scope}}".': + '擴展 "{{name}}" 已在作用域 "{{scope}}" 中啓用。', + 'Extension "{{name}}" successfully enabled in all scopes.': + '擴展 "{{name}}" 已在所有作用域中啓用。', + 'Invalid scope: {{scope}}. Please use one of {{scopes}}.': + '無效的作用域:{{scope}}。請使用 {{scopes}} 之一。', + 'Disables an extension.': '禁用擴展。', + 'The name of the extension to disable.': '要禁用的擴展名稱。', + 'The scope to disable the extenison in.': '禁用擴展的作用域。', + 'Extension "{{name}}" successfully disabled for scope "{{scope}}".': + '擴展 "{{name}}" 已在作用域 "{{scope}}" 中禁用。', + 'Extension "{{name}}" successfully updated: {{oldVersion}} → {{newVersion}}.': + '擴展 "{{name}}" 更新成功:{{oldVersion}} → {{newVersion}}。', + 'Unable to install extension "{{name}}" due to missing install metadata': + '由於缺少安裝元數據,無法安裝擴展 "{{name}}"', + 'Extension "{{name}}" is already up to date.': + '擴展 "{{name}}" 已是最新版本。', + 'Updates all extensions or a named extension to the latest version.': + '將所有擴展或指定擴展更新到最新版本。', + 'Update all extensions.': '更新所有擴展。', + 'The name of the extension to update.': '要更新的擴展名稱。', + 'Either an extension name or --all must be provided': + '必須提供擴展名稱或 --all', + 'Lists installed extensions.': '列出已安裝的擴展。', + 'Path:': '路徑:', + 'Source:': '來源:', + 'Type:': '類型:', + 'Ref:': '引用:', + 'Release tag:': '發佈標籤:', + 'Enabled (User):': '已啓用(用戶):', + 'Enabled (Workspace):': '已啓用(工作區):', + 'Context files:': '上下文文件:', + 'Skills:': '技能:', + 'Agents:': '智能體:', + 'MCP servers:': 'MCP 服務器:', + 'Link extension failed to install.': '鏈接擴展安裝失敗。', + 'Extension "{{name}}" linked successfully and enabled.': + '擴展 "{{name}}" 鏈接成功並已啓用。', + 'Links an extension from a local path. Updates made to the local path will always be reflected.': + '從本地路徑鏈接擴展。對本地路徑的更新將始終反映。', + 'The name of the extension to link.': '要鏈接的擴展名稱。', + 'Set a specific setting for an extension.': '爲擴展設置特定配置。', + 'Name of the extension to configure.': '要配置的擴展名稱。', + 'The setting to configure (name or env var).': + '要配置的設置(名稱或環境變量)。', + 'The scope to set the setting in.': '設置配置的作用域。', + 'List all settings for an extension.': '列出擴展的所有設置。', + 'Name of the extension.': '擴展名稱。', + 'Extension "{{name}}" has no settings to configure.': + '擴展 "{{name}}" 沒有可配置的設置。', + 'Settings for "{{name}}":': '"{{name}}" 的設置:', + '(workspace)': '(工作區)', + '(user)': '(用戶)', + '[not set]': '[未設置]', + '[value stored in keychain]': '[值存儲在鑰匙串中]', + 'Value:': '值:', + 'Manage extension settings.': '管理擴展設置。', + 'You need to specify a command (set or list).': + '您需要指定命令(set 或 list)。', + 'No plugins available in this marketplace.': '此市場中沒有可用的插件。', + 'Select a plugin to install from marketplace "{{name}}":': + '從市場 "{{name}}" 中選擇要安裝的插件:', + 'Plugin selection cancelled.': '插件選擇已取消。', + 'Select a plugin from "{{name}}"': '從 "{{name}}" 中選擇插件', + 'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel': + '使用 ↑↓ 或 j/k 導航,回車選擇,Esc 取消', + '{{count}} more above': '上方還有 {{count}} 項', + '{{count}} more below': '下方還有 {{count}} 項', + 'manage IDE integration': '管理 IDE 集成', + 'check status of IDE integration': '檢查 IDE 集成狀態', + 'install required IDE companion for {{ideName}}': + '安裝 {{ideName}} 所需的 IDE 配套工具', + 'enable IDE integration': '啓用 IDE 集成', + 'disable IDE integration': '禁用 IDE 集成', + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.': + '您當前環境不支持 IDE 集成。要使用此功能,請在以下支持的 IDE 之一中運行 Qwen Code:VS Code 或 VS Code 分支版本。', + 'Set up GitHub Actions': '設置 GitHub Actions', + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)': + '配置終端按鍵綁定以支持多行輸入(VS Code、Cursor、Windsurf、Trae)', + 'Please restart your terminal for the changes to take effect.': + '請重啓終端以使更改生效。', + 'Failed to configure terminal: {{error}}': '配置終端失敗:{{error}}', + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.': + '無法確定 {{terminalName}} 在 Windows 上的配置路徑:未設置 APPDATA 環境變量。', + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.': + '{{terminalName}} keybindings.json 存在但不是有效的 JSON 數組。請手動修復文件或刪除它以允許自動配置。', + 'File: {{file}}': '文件:{{file}}', + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': + '解析 {{terminalName}} keybindings.json 失敗。文件包含無效的 JSON。請手動修復文件或刪除它以允許自動配置。', + 'Error: {{error}}': '錯誤:{{error}}', + 'Shift+Enter binding already exists': 'Shift+Enter 綁定已存在', + 'Ctrl+Enter binding already exists': 'Ctrl+Enter 綁定已存在', + 'Existing keybindings detected. Will not modify to avoid conflicts.': + '檢測到現有按鍵綁定。爲避免衝突,不會修改。', + 'Please check and modify manually if needed: {{file}}': + '如有需要,請手動檢查並修改:{{file}}', + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.': + '已爲 {{terminalName}} 添加 Shift+Enter 和 Ctrl+Enter 按鍵綁定。', + 'Modified: {{file}}': '已修改:{{file}}', + '{{terminalName}} keybindings already configured.': + '{{terminalName}} 按鍵綁定已配置。', + 'Failed to configure {{terminalName}}.': '配置 {{terminalName}} 失敗。', + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': + '您的終端已配置爲支持多行輸入(Shift+Enter 和 Ctrl+Enter)的最佳體驗。', + 'Manage Qwen Code hooks': '管理 Qwen Code Hook', + 'List all configured hooks': '列出所有已配置的 Hook', + 'Enable a disabled hook': '啓用已禁用的 Hook', + 'Disable an active hook': '禁用已啓用的 Hook', + Hooks: 'Hook', + 'Loading hooks...': '正在加載 Hook...', + 'Error loading hooks:': '加載 Hook 出錯:', + 'Press Escape to close': '按 Escape 關閉', + 'Press Escape, Ctrl+C, or Ctrl+D to cancel': + '按 Escape、Ctrl+C 或 Ctrl+D 取消', + 'Press Space, Enter, or Escape to dismiss': '按空格、回車或 Escape 關閉', + 'No hook selected': '未選擇 Hook', + 'No hook events found.': '未找到 Hook 事件。', + '{{count}} hook configured': '{{count}} 個 Hook 已配置', + '{{count}} hooks configured': '{{count}} 個 Hook 已配置', + 'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.': + '此菜單爲只讀。要添加或修改 Hook,請直接編輯 settings.json 或詢問 Qwen Code。', + 'Enter to select · Esc to cancel': 'Enter 選擇 · Esc 取消', + 'Exit codes:': '退出碼:', + 'Configured hooks:': '已配置的 Hook:', + 'No hooks configured for this event.': '此事件未配置 Hook。', + 'To add hooks, edit settings.json directly or ask Qwen.': + '要添加 Hook,請直接編輯 settings.json 或詢問 Qwen。', + 'Enter to select · Esc to go back': 'Enter 選擇 · Esc 返回', + 'Hook details': 'Hook 詳情', + 'Event:': '事件:', + 'Extension:': '擴展:', + 'Desc:': '描述:', + 'No hook config selected': '未選擇 Hook 配置', + 'To modify or remove this hook, edit settings.json directly or ask Qwen to help.': + '要修改或刪除此 Hook,請直接編輯 settings.json 或詢問 Qwen。', + 'Hook Configuration - Disabled': 'Hook 配置 - 已禁用', + 'All hooks are currently disabled. You have {{count}} that are not running.': + '所有 Hook 當前已禁用。您有 {{count}} 未運行。', + '{{count}} configured hook': '{{count}} 個已配置的 Hook', + '{{count}} configured hooks': '{{count}} 個已配置的 Hook', + 'When hooks are disabled:': '當 Hook 被禁用時:', + 'No hook commands will execute': '不會執行任何 Hook 命令', + 'StatusLine will not be displayed': '不會顯示狀態欄', + 'Tool operations will proceed without hook validation': + '工具操作將在沒有 Hook 驗證的情況下繼續', + 'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.': + '要重新啓用 Hook,請從 settings.json 中刪除 "disableAllHooks" 或詢問 Qwen Code。', + Project: '項目', + User: '用戶', + System: '系統', + Extension: '擴展', + 'Local Settings': '本地設置', + 'User Settings': '用戶設置', + 'System Settings': '系統設置', + Extensions: '擴展', + 'Session (temporary)': '會話(臨時)', + '✓ Enabled': '✓ 已啓用', + '✗ Disabled': '✗ 已禁用', + 'Before tool execution': '工具執行前', + 'After tool execution': '工具執行後', + 'After tool execution fails': '工具執行失敗後', + 'When notifications are sent': '發送通知時', + 'When the user submits a prompt': '用戶提交提示時', + 'When a new session is started': '新會話開始時', + 'Right before Qwen Code concludes its response': 'Qwen Code 結束響應之前', + 'When a subagent (Agent tool call) is started': + '子智能體(Agent 工具調用)啓動時', + 'Right before a subagent concludes its response': '子智能體結束響應之前', + 'Before conversation compaction': '對話壓縮前', + 'When a session is ending': '會話結束時', + 'When a permission dialog is displayed': '顯示權限對話框時', + 'Input to command is JSON of tool call arguments.': + '命令輸入爲工具調用參數的 JSON。', + 'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).': + '命令輸入爲包含 "inputs"(工具調用參數)和 "response"(工具調用響應)字段的 JSON。', + 'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.': + '命令輸入爲包含 tool_name、tool_input、tool_use_id、error、error_type、is_interrupt 和 is_timeout 的 JSON。', + 'Input to command is JSON with notification message and type.': + '命令輸入爲包含通知消息和類型的 JSON。', + 'Input to command is JSON with original user prompt text.': + '命令輸入爲包含原始用戶提示文本的 JSON。', + 'Input to command is JSON with session start source.': + '命令輸入爲包含會話啓動來源的 JSON。', + 'Input to command is JSON with session end reason.': + '命令輸入爲包含會話結束原因的 JSON。', + 'Input to command is JSON with agent_id and agent_type.': + '命令輸入爲包含 agent_id 和 agent_type 的 JSON。', + 'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.': + '命令輸入爲包含 agent_id、agent_type 和 agent_transcript_path 的 JSON。', + 'Input to command is JSON with compaction details.': + '命令輸入爲包含壓縮詳情的 JSON。', + 'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.': + '命令輸入爲包含 tool_name、tool_input 和 tool_use_id 的 JSON。輸出包含 hookSpecificOutput 的 JSON,其中包含允許或拒絕的決定。', + 'stdout/stderr not shown': 'stdout/stderr 不顯示', + 'show stderr to model and continue conversation': + '向模型顯示 stderr 並繼續對話', + 'show stderr to user only': '僅向用戶顯示 stderr', + 'stdout shown in transcript mode (ctrl+o)': 'stdout 以轉錄模式顯示 (ctrl+o)', + 'show stderr to model immediately': '立即向模型顯示 stderr', + 'show stderr to user only but continue with tool call': + '僅向用戶顯示 stderr 但繼續工具調用', + 'block processing, erase original prompt, and show stderr to user only': + '阻止處理,擦除原始提示,僅向用戶顯示 stderr', + 'stdout shown to Qwen': '向 Qwen 顯示 stdout', + 'show stderr to user only (blocking errors ignored)': + '僅向用戶顯示 stderr(忽略阻塞錯誤)', + 'command completes successfully': '命令成功完成', + 'stdout shown to subagent': '向子智能體顯示 stdout', + 'show stderr to subagent and continue having it run': + '向子智能體顯示 stderr 並繼續運行', + 'stdout appended as custom compact instructions': + 'stdout 作爲自定義壓縮指令追加', + 'block compaction': '阻止壓縮', + 'show stderr to user only but continue with compaction': + '僅向用戶顯示 stderr 但繼續壓縮', + 'use hook decision if provided': '如果提供則使用 Hook 決定', + 'Config not loaded.': '配置未加載。', + 'Hooks are not enabled. Enable hooks in settings to use this feature.': + 'Hook 未啓用。請在設置中啓用 Hook 以使用此功能。', + 'No hooks configured. Add hooks in your settings.json file.': + '未配置 Hook。請在 settings.json 文件中添加 Hook。', + 'Configured Hooks ({{count}} total)': '已配置的 Hook(共 {{count}} 個)', + 'Export current session message history to a file': + '將當前會話的消息記錄導出到文件', + 'Export session to HTML format': '將會話導出爲 HTML 文件', + 'Export session to JSON format': '將會話導出爲 JSON 文件', + 'Export session to JSONL format (one message per line)': + '將會話導出爲 JSONL 文件(每行一條消息)', + 'Export session to markdown format': '將會話導出爲 Markdown 文件', + 'generate personalized programming insights from your chat history': + '根據你的聊天記錄生成個性化編程洞察', + 'Resume a previous session': '恢復先前會話', + 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested': + '恢復某次工具調用。這將把對話與文件歷史重置到提出該工具調用建議時的狀態', + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': + '無法檢測終端類型。支持的終端:VS Code、Cursor、Windsurf 和 Trae。', + 'Terminal "{{terminal}}" is not supported yet.': + '終端 "{{terminal}}" 尚未支持。', + 'Invalid language. Available: {{options}}': + '無效的語言。可用選項:{{options}}', + 'Language subcommands do not accept additional arguments.': + '語言子命令不接受額外參數', + 'Current UI language: {{lang}}': '當前 UI 語言:{{lang}}', + 'Current LLM output language: {{lang}}': '當前 LLM 輸出語言:{{lang}}', + 'LLM output language not set': '未設置 LLM 輸出語言', + 'Set UI language': '設置 UI 語言', + 'Set LLM output language': '設置 LLM 輸出語言', + 'Usage: /language ui [{{options}}]': '用法:/language ui [{{options}}]', + 'Usage: /language output ': '用法:/language output <語言>', + 'Example: /language output 中文': '示例:/language output 中文', + 'Example: /language output English': '示例:/language output English', + 'Example: /language output 日本語': '示例:/language output 日本語', + 'Example: /language output Português': '示例:/language output Português', + 'UI language changed to {{lang}}': 'UI 語言已更改爲 {{lang}}', + 'LLM output language set to {{lang}}': 'LLM 輸出語言已設置爲 {{lang}}', + 'LLM output language rule file generated at {{path}}': + 'LLM 輸出語言規則文件已生成於 {{path}}', + 'Please restart the application for the changes to take effect.': + '請重啓應用程序以使更改生效。', + 'Failed to generate LLM output language rule file: {{error}}': + '生成 LLM 輸出語言規則文件失敗:{{error}}', + 'Invalid command. Available subcommands:': '無效的命令。可用的子命令:', + 'Available subcommands:': '可用的子命令:', + 'To request additional UI language packs, please open an issue on GitHub.': + '如需請求其他 UI 語言包,請在 GitHub 上提交 issue', + 'Available options:': '可用選項:', + 'Set UI language to {{name}}': '將 UI 語言設置爲 {{name}}', + 'Tool Approval Mode': '工具審批模式', + 'Current approval mode: {{mode}}': '當前審批模式:{{mode}}', + 'Available approval modes:': '可用的審批模式:', + 'Approval mode changed to: {{mode}}': '審批模式已更改爲:{{mode}}', + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': + '審批模式已更改爲:{{mode}}(已保存到{{scope}}設置{{location}})', + 'Usage: /approval-mode [--session|--user|--project]': + '用法:/approval-mode [--session|--user|--project]', + 'Scope subcommands do not accept additional arguments.': + '作用域子命令不接受額外參數', + 'Plan mode - Analyze only, do not modify files or execute commands': + '規劃模式 - 僅分析,不修改文件或執行命令', + 'Default mode - Require approval for file edits or shell commands': + '默認模式 - 需要批准文件編輯或 shell 命令', + 'Auto-edit mode - Automatically approve file edits': + '自動編輯模式 - 自動批准文件編輯', + 'YOLO mode - Automatically approve all tools': 'YOLO 模式 - 自動批准所有工具', + '{{mode}} mode': '{{mode}} 模式', + 'Settings service is not available; unable to persist the approval mode.': + '設置服務不可用;無法持久化審批模式。', + 'Failed to save approval mode: {{error}}': '保存審批模式失敗:{{error}}', + 'Failed to change approval mode: {{error}}': '更改審批模式失敗:{{error}}', + 'Apply to current session only (temporary)': '僅應用於當前會話(臨時)', + 'Persist for this project/workspace': '持久化到此項目/工作區', + 'Persist for this user on this machine': '持久化到此機器上的此用戶', + 'Analyze only, do not modify files or execute commands': + '僅分析,不修改文件或執行命令', + 'Require approval for file edits or shell commands': + '需要批准文件編輯或 shell 命令', + 'Automatically approve file edits': '自動批准文件編輯', + 'Automatically approve all tools': '自動批准所有工具', + 'Workspace approval mode exists and takes priority. User-level change will have no effect.': + '工作區審批模式已存在並具有優先級。用戶級別的更改將無效。', + 'Apply To': '應用於', + 'Workspace Settings': '工作區設置', + 'Commands for interacting with memory.': '用於與記憶交互的命令', + 'Show the current memory contents.': '顯示當前記憶內容', + 'Show project-level memory contents.': '顯示項目級記憶內容', + 'Show global memory contents.': '顯示全局記憶內容', + 'Add content to project-level memory.': '添加內容到項目級記憶', + 'Add content to global memory.': '添加內容到全局記憶', + 'Refresh the memory from the source.': '從源刷新記憶', + 'Usage: /memory add --project ': + '用法:/memory add --project <要記住的文本>', + 'Usage: /memory add --global ': + '用法:/memory add --global <要記住的文本>', + 'Attempting to save to project memory: "{{text}}"': + '正在嘗試保存到項目記憶:"{{text}}"', + 'Attempting to save to global memory: "{{text}}"': + '正在嘗試保存到全局記憶:"{{text}}"', + 'Current memory content from {{count}} file(s):': + '來自 {{count}} 個文件的當前記憶內容:', + 'Memory is currently empty.': '記憶當前爲空', + 'Project memory file not found or is currently empty.': + '項目記憶文件未找到或當前爲空', + 'Global memory file not found or is currently empty.': + '全局記憶文件未找到或當前爲空', + 'Global memory is currently empty.': '全局記憶當前爲空', + 'Global memory content:\n\n---\n{{content}}\n---': + '全局記憶內容:\n\n---\n{{content}}\n---', + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---': + '項目記憶內容來自 {{path}}:\n\n---\n{{content}}\n---', + 'Project memory is currently empty.': '項目記憶當前爲空', + 'Refreshing memory from source files...': '正在從源文件刷新記憶...', + 'Add content to the memory. Use --global for global memory or --project for project memory.': + '添加內容到記憶。使用 --global 表示全局記憶,使用 --project 表示項目記憶', + 'Usage: /memory add [--global|--project] ': + '用法:/memory add [--global|--project] <要記住的文本>', + 'Attempting to save to memory {{scope}}: "{{fact}}"': + '正在嘗試保存到記憶 {{scope}}:"{{fact}}"', + 'Open auto-memory folder': '打開自動記憶文件夾', + 'Auto-memory: {{status}}': '自動記憶:{{status}}', + 'Auto-dream: {{status}} · {{lastDream}} · /dream to run': + '自動整理:{{status}} · {{lastDream}} · /dream 立即運行', + never: '從未', + on: '開', + off: '關', + '✦ dreaming': '✦ 整理中', + 'Remove matching entries from managed auto-memory.': + '從託管自動記憶中刪除匹配的條目。', + 'Usage: /forget ': '用法:/forget <要刪除的記憶文本>', + 'No managed auto-memory entries matched: {{query}}': + '沒有匹配的託管自動記憶條目:{{query}}', + 'Show managed auto-memory status.': '顯示託管自動記憶狀態', + 'Run managed auto-memory extraction for the current session.': + '爲當前會話運行託管自動記憶提煉', + 'Managed auto-memory root: {{root}}': '託管自動記憶根目錄:{{root}}', + 'Managed auto-memory topics:': '託管自動記憶主題:', + 'No extraction cursor found yet.': '尚未找到提煉遊標。', + 'Cursor: session={{sessionId}}, offset={{offset}}, updated={{updatedAt}}': + '遊標:session={{sessionId}},offset={{offset}},updated={{updatedAt}}', + 'No chat client available to extract memory.': + '沒有可用於提煉記憶的聊天客戶端。', + 'Managed auto-memory extraction is already running.': + '託管自動記憶提煉已在運行中。', + 'Managed auto-memory extraction found no new durable memories.': + '託管自動記憶提煉未發現新的持久記憶。', + 'Consolidate managed auto-memory topic files.': '整理託管自動記憶主題文件', + 'Managed auto-memory dream found nothing to improve.': + '託管自動記憶 dream 未發現可改進內容。', + 'Deduplicated entries: {{count}}': '去重條目數:{{count}}', + 'Save a durable memory using the save_memory tool.': + '使用 save_memory 工具保存一條持久記憶', + 'Usage: /remember [--global|--project] ': + '用法:/remember [--global|--project] <要記住的文本>', + 'Authenticate with an OAuth-enabled MCP server': + '使用支持 OAuth 的 MCP 服務器進行認證', + 'List configured MCP servers and tools': '列出已配置的 MCP 服務器和工具', + 'Restarts MCP servers.': '重啓 MCP 服務器', + 'Open MCP management dialog': '打開 MCP 管理對話框', + 'Could not retrieve tool registry.': '無法檢索工具註冊表', + 'No MCP servers configured with OAuth authentication.': + '未配置支持 OAuth 認證的 MCP 服務器', + 'MCP servers with OAuth authentication:': '支持 OAuth 認證的 MCP 服務器:', + 'Use /mcp auth to authenticate.': + '使用 /mcp auth 進行認證', + "MCP server '{{name}}' not found.": "未找到 MCP 服務器 '{{name}}'", + "Successfully authenticated and refreshed tools for '{{name}}'.": + "成功認證並刷新了 '{{name}}' 的工具", + "Failed to authenticate with MCP server '{{name}}': {{error}}": + "認證 MCP 服務器 '{{name}}' 失敗:{{error}}", + "Re-discovering tools from '{{name}}'...": + "正在重新發現 '{{name}}' 的工具...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "從 '{{name}}' 發現了 {{count}} 個工具。", + 'Authentication complete. Returning to server details...': + '認證完成,正在返回服務器詳情...', + 'Authentication successful.': '認證成功。', + 'If the browser does not open, copy and paste this URL into your browser:': + '如果瀏覽器未自動打開,請複製以下 URL 並粘貼到瀏覽器中:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ 請確保複製完整的 URL —— 它可能跨越多行。', + 'Manage MCP servers': '管理 MCP 服務器', + 'Server Detail': '服務器詳情', + 'Disable Server': '禁用服務器', + Tools: '工具', + 'Tool Detail': '工具詳情', + 'MCP Management': 'MCP 管理', + 'Loading...': '加載中...', + 'Unknown step': '未知步驟', + 'Esc to back': 'Esc 返回', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ 導航 · Enter 選擇 · Esc 關閉', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ 導航 · Enter 選擇 · Esc 返回', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ 導航 · Enter 確認 · Esc 返回', + 'User Settings (global)': '用戶設置(全局)', + 'Workspace Settings (project-specific)': '工作區設置(項目級)', + 'Disable server:': '禁用服務器:', + 'Select where to add the server to the exclude list:': + '選擇將服務器添加到排除列表的位置:', + 'Press Enter to confirm, Esc to cancel': '按 Enter 確認,Esc 取消', + 'View tools': '查看工具', + Reconnect: '重新連接', + Enable: '啓用', + Disable: '禁用', + Authenticate: '認證', + 'Re-authenticate': '重新認證', + 'Clear Authentication': '清空認證', + 'Server:': '服務器:', + 'Command:': '命令:', + 'Working Directory:': '工作目錄:', + 'Capabilities:': '功能:', + 'No server selected': '未選擇服務器', + prompts: '提示詞', + '(disabled)': '(已禁用)', + 'Error:': '錯誤:', + tool: '工具', + tools: '個工具', + connected: '已連接', + connecting: '連接中', + disconnected: '已斷開', + 'User MCPs': '用戶 MCP', + 'Project MCPs': '項目 MCP', + 'Extension MCPs': '擴展 MCP', + server: '個服務器', + servers: '個服務器', + 'Add MCP servers to your settings to get started.': + '請在設置中添加 MCP 服務器以開始使用。', + 'Run qwen --debug to see error logs': '運行 qwen --debug 查看錯誤日誌', + 'OAuth Authentication': 'OAuth 認證', + 'Press Enter to start authentication, Esc to go back': + '按 Enter 開始認證,Esc 返回', + 'Authenticating... Please complete the login in your browser.': + '認證中... 請在瀏覽器中完成登錄。', + 'Press c to copy the authorization URL to your clipboard.': + '按 c 複製授權 URL 到剪貼板。', + 'Copy request sent to your terminal. If paste is empty, copy the URL above manually.': + '已向終端發送複製請求;若粘貼爲空,請手動複製上方 URL。', + 'Cannot write to terminal — copy the URL above manually.': + '無法寫入終端,請手動複製上方 URL。', + 'Press Enter or Esc to go back': '按 Enter 或 Esc 返回', + 'No tools available for this server.': '此服務器沒有可用工具。', + destructive: '破壞性', + 'read-only': '只讀', + 'open-world': '開放世界', + idempotent: '冪等', + 'Tools for {{name}}': '{{name}} 的工具', + 'Tools for {{serverName}}': '{{serverName}} 的工具', + '{{current}}/{{total}}': '{{current}}/{{total}}', + required: '必填', + Type: '類型', + Enum: '枚舉', + Parameters: '參數', + 'No tool selected': '未選擇工具', + Annotations: '註解', + Title: '標題', + 'Read Only': '只讀', + Destructive: '破壞性', + Idempotent: '冪等', + 'Open World': '開放世界', + Server: '服務器', + '{{count}} invalid tools': '{{count}} 個無效工具', + invalid: '無效', + 'invalid: {{reason}}': '無效:{{reason}}', + 'missing name': '缺少名稱', + 'missing description': '缺少描述', + '(unnamed)': '(未命名)', + 'Warning: This tool cannot be called by the LLM': + '警告:此工具無法被 LLM 調用', + Reason: '原因', + 'Tools must have both name and description to be used by the LLM.': + '工具必須同時具有名稱和描述才能被 LLM 使用。', + 'Manage conversation history.': '管理對話歷史', + 'List saved conversation checkpoints': '列出已保存的對話檢查點', + 'No saved conversation checkpoints found.': '未找到已保存的對話檢查點', + 'List of saved conversations:': '已保存的對話列表:', + 'Note: Newest last, oldest first': '注意:最新的在最後,最舊的在最前', + 'Save the current conversation as a checkpoint. Usage: /chat save ': + '將當前對話保存爲檢查點。用法:/chat save ', + 'Missing tag. Usage: /chat save ': '缺少標籤。用法:/chat save ', + 'Delete a conversation checkpoint. Usage: /chat delete ': + '刪除對話檢查點。用法:/chat delete ', + 'Missing tag. Usage: /chat delete ': + '缺少標籤。用法:/chat delete ', + "Conversation checkpoint '{{tag}}' has been deleted.": + "對話檢查點 '{{tag}}' 已刪除", + "Error: No checkpoint found with tag '{{tag}}'.": + "錯誤:未找到標籤爲 '{{tag}}' 的檢查點", + 'Resume a conversation from a checkpoint. Usage: /chat resume ': + '從檢查點恢復對話。用法:/chat resume ', + 'Missing tag. Usage: /chat resume ': + '缺少標籤。用法:/chat resume ', + 'No saved checkpoint found with tag: {{tag}}.': + '未找到標籤爲 {{tag}} 的已保存檢查點', + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?': + '標籤爲 {{tag}} 的檢查點已存在。您要覆蓋它嗎?', + 'No chat client available to save conversation.': + '沒有可用的聊天客戶端來保存對話', + 'Conversation checkpoint saved with tag: {{tag}}.': + '對話檢查點已保存,標籤:{{tag}}', + 'No conversation found to save.': '未找到要保存的對話', + 'No chat client available to share conversation.': + '沒有可用的聊天客戶端來分享對話', + 'Invalid file format. Only .md and .json are supported.': + '無效的文件格式。僅支持 .md 和 .json 文件', + 'Error sharing conversation: {{error}}': '分享對話時出錯:{{error}}', + 'Conversation shared to {{filePath}}': '對話已分享到 {{filePath}}', + 'No conversation found to share.': '未找到要分享的對話', + 'Share the current conversation to a markdown or json file. Usage: /chat share ': + '將當前對話分享到 markdown 或 json 文件。用法:/chat share ', + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md': + '生成項目摘要並保存到 .qwen/PROJECT_SUMMARY.md', + 'No chat client available to generate summary.': + '沒有可用的聊天客戶端來生成摘要', + 'Already generating summary, wait for previous request to complete': + '正在生成摘要,請等待上一個請求完成', + 'No conversation found to summarize.': '未找到要總結的對話', + 'Failed to generate project context summary: {{error}}': + '生成項目上下文摘要失敗:{{error}}', + 'Saved project summary to {{filePathForDisplay}}.': + '項目摘要已保存到 {{filePathForDisplay}}', + 'Saving project summary...': '正在保存項目摘要...', + 'Generating project summary...': '正在生成項目摘要...', + 'Failed to generate summary - no text content received from LLM response': + '生成摘要失敗 - 未從 LLM 響應中接收到文本內容', + 'Switch the model for this session (--fast for suggestion model)': + '切換此會話的模型(--fast 可設置建議模型)', + 'Set a lighter model for prompt suggestions and speculative execution': + '設置用於輸入建議和推測執行的輕量模型', + 'Content generator configuration not available.': '內容生成器配置不可用', + 'Authentication type not available.': '認證類型不可用', + 'No models available for the current authentication type ({{authType}}).': + '當前認證類型 ({{authType}}) 沒有可用的模型', + 'Starting a new session, resetting chat, and clearing terminal.': + '正在開始新會話,重置聊天並清屏。', + 'Starting a new session and clearing.': '正在開始新會話並清屏。', + 'Already compressing, wait for previous request to complete': + '正在壓縮中,請等待上一個請求完成', + 'Failed to compress chat history.': '壓縮聊天曆史失敗', + 'Failed to compress chat history: {{error}}': '壓縮聊天曆史失敗:{{error}}', + 'Compressing chat history': '正在壓縮聊天曆史', + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.': + '聊天曆史已從 {{originalTokens}} 個 token 壓縮到 {{newTokens}} 個 token。', + 'Compression was not beneficial for this history size.': + '對於此歷史記錄大小,壓縮沒有益處。', + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.': + '聊天曆史壓縮未能減小大小。這可能表明壓縮提示存在問題。', + 'Could not compress chat history due to a token counting error.': + '由於 token 計數錯誤,無法壓縮聊天曆史。', + 'Chat history is already compressed.': '聊天曆史已經壓縮。', + 'Configuration is not available.': '配置不可用。', + 'Please provide at least one path to add.': '請提供至少一個要添加的路徑。', + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': + '/directory add 命令在限制性沙箱配置文件中不受支持。請改爲在啓動會話時使用 --include-directories。', + "Error adding '{{path}}': {{error}}": "添加 '{{path}}' 時出錯:{{error}}", + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}': + '如果存在,已成功從以下目錄添加 QWEN.md 文件:\n- {{directories}}', + 'Error refreshing memory: {{error}}': '刷新內存時出錯:{{error}}', + 'Successfully added directories:\n- {{directories}}': + '成功添加目錄:\n- {{directories}}', + 'Current workspace directories:\n{{directories}}': + '當前工作區目錄:\n{{directories}}', + 'Please open the following URL in your browser to view the documentation:\n{{url}}': + '請在瀏覽器中打開以下 URL 以查看文檔:\n{{url}}', + 'Opening documentation in your browser: {{url}}': + '正在瀏覽器中打開文檔:{{url}}', + 'Do you want to proceed?': '是否繼續?', + 'Yes, allow once': '是,允許一次', + 'Allow always': '總是允許', + Yes: '是', + No: '否', + 'No (esc)': '否 (esc)', + 'Yes, allow always for this session': '是,本次會話總是允許', + 'Modify in progress:': '正在修改:', + 'Save and close external editor to continue': '保存並關閉外部編輯器以繼續', + 'Apply this change?': '是否應用此更改?', + 'Yes, allow always': '是,總是允許', + 'Modify with external editor': '使用外部編輯器修改', + 'No, suggest changes (esc)': '否,建議更改 (esc)', + "Allow execution of: '{{command}}'?": "允許執行:'{{command}}'?", + 'Yes, allow always ...': '是,總是允許 ...', + 'Always allow in this project': '在本項目中總是允許', + 'Always allow {{action}} in this project': '在本項目中總是允許{{action}}', + 'Always allow for this user': '對該用戶總是允許', + 'Always allow {{action}} for this user': '對該用戶總是允許{{action}}', + 'Yes, restore previous mode ({{mode}})': '是,恢復之前的模式 ({{mode}})', + 'Yes, and auto-accept edits': '是,並自動接受編輯', + 'Yes, and manually approve edits': '是,並手動批准編輯', + 'No, keep planning (esc)': '否,繼續規劃 (esc)', + 'URLs to fetch:': '要獲取的 URL:', + 'MCP Server: {{server}}': 'MCP 服務器:{{server}}', + 'Tool: {{tool}}': '工具:{{tool}}', + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?': + '允許執行來自服務器 "{{server}}" 的 MCP 工具 "{{tool}}"?', + 'Yes, always allow tool "{{tool}}" from server "{{server}}"': + '是,總是允許來自服務器 "{{server}}" 的工具 "{{tool}}"', + 'Yes, always allow all tools from server "{{server}}"': + '是,總是允許來自服務器 "{{server}}" 的所有工具', + 'Shell Command Execution': 'Shell 命令執行', + 'A custom command wants to run the following shell commands:': + '自定義命令想要運行以下 shell 命令:', + 'Pro quota limit reached for {{model}}.': '{{model}} 的 Pro 配額已達到上限', + 'Change auth (executes the /auth command)': '更改認證(執行 /auth 命令)', + 'Continue with {{model}}': '使用 {{model}} 繼續', + 'Current Plan:': '當前計劃:', + 'Progress: {{done}}/{{total}} tasks completed': + '進度:已完成 {{done}}/{{total}} 個任務', + ', {{inProgress}} in progress': ',{{inProgress}} 個進行中', + 'Pending Tasks:': '待處理任務:', + 'What would you like to do?': '您想要做什麼?', + 'Choose how to proceed with your session:': '選擇如何繼續您的會話:', + 'Start new chat session': '開始新的聊天會話', + 'Continue previous conversation': '繼續之前的對話', + '👋 Welcome back! (Last updated: {{timeAgo}})': + '👋 歡迎回來!(最後更新:{{timeAgo}})', + '🎯 Overall Goal:': '🎯 總體目標:', + 'Get started': '開始使用', + '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 退出', + 'Terms of Services and Privacy Notice': '服務條款和隱私聲明', + 'Qwen OAuth': 'Qwen OAuth (免費)', + 'Discontinued — switch to Coding Plan or API Key': + '已停用 — 請切換到 Coding Plan 或 API Key', + 'Qwen OAuth free tier was discontinued on 2026-04-15. Run /auth to switch provider.': + 'Qwen OAuth 免費額度已於 2026-04-15 停用。請運行 /auth 切換服務商。', + 'Qwen OAuth free tier was discontinued on 2026-04-15. Please select Coding Plan or API Key instead.': + 'Qwen OAuth 免費額度已於 2026-04-15 停用。請選擇 Coding Plan 或 API Key。', + 'Qwen OAuth free tier was discontinued on 2026-04-15. Please select a model from another provider or run /auth to switch.': + 'Qwen OAuth免費層已於2026-04-15停止服務。請選擇其他提供商的模型或運行 /auth 切換。', + '\n⚠ Qwen OAuth free tier was discontinued on 2026-04-15. Please select another option.\n': + '\n⚠ Qwen OAuth 免費額度已於 2026-04-15 停用。請選擇其他選項。\n', + 'Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models': + '付費 · 每 5 小時最多 6,000 次請求 · 支持阿里雲百鍊 Coding Plan 全部模型', + 'Alibaba Cloud Coding Plan': '阿里雲百鍊 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 密鑰/提供商。', + OpenAI: 'OpenAI', + 'Failed to login. Message: {{message}}': '登錄失敗。消息:{{message}}', + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': + '認證方式被強制設置爲 {{enforcedType}},但您當前使用的是 {{currentType}}', + 'Qwen OAuth authentication timed out. Please try again.': + 'Qwen OAuth 認證超時。請重試', + 'Qwen OAuth authentication cancelled.': 'Qwen OAuth 認證已取消', + 'Qwen OAuth Authentication': 'Qwen OAuth 認證', + 'Please visit this URL to authorize:': '請訪問此 URL 進行授權:', + 'Or scan the QR code below:': '或掃描下方的二維碼:', + 'Waiting for authorization': '等待授權中', + 'Time remaining:': '剩餘時間:', + '(Press ESC or CTRL+C to cancel)': '(按 ESC 或 CTRL+C 取消)', + 'Qwen OAuth Authentication Timeout': 'Qwen OAuth 認證超時', + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': + 'OAuth 令牌已過期(超過 {{seconds}} 秒)。請重新選擇認證方法', + 'Press any key to return to authentication type selection.': + '按任意鍵返回認證類型選擇', + 'Waiting for Qwen OAuth authentication...': '正在等待 Qwen OAuth 認證...', + 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.': + '注意:使用 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 取消)', + 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.': + '缺少 OpenAI 兼容認證的 API 密鑰。請設置 settings.security.auth.apiKey 或設置 {{envKeyHint}} 環境變量。', + '{{envKeyHint}} environment variable not found.': + '未找到 {{envKeyHint}} 環境變量。', + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': + '未找到 {{envKeyHint}} 環境變量。請在 .env 文件或系統環境變量中進行設置。', + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.': + '未找到 {{envKeyHint}} 環境變量(或設置 settings.security.auth.apiKey)。請在 .env 文件或系統環境變量中進行設置。', + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.': + '缺少 OpenAI 兼容認證的 API 密鑰。請設置 {{envKeyHint}} 環境變量。', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.': + 'Anthropic 提供商缺少必需的 baseUrl,請在 modelProviders[].baseUrl 中配置。', + 'ANTHROPIC_BASE_URL environment variable not found.': + '未找到 ANTHROPIC_BASE_URL 環境變量。', + 'Invalid auth method selected.': '選擇了無效的認證方式。', + 'Failed to authenticate. Message: {{message}}': '認證失敗。消息:{{message}}', + 'Authenticated successfully with {{authType}} credentials.': + '使用 {{authType}} 憑據成功認證。', + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': + '無效的 QWEN_DEFAULT_AUTH_TYPE 值:"{{value}}"。有效值爲:{{validValues}}', + 'OpenAI Configuration Required': '需要配置 OpenAI', + 'Please enter your OpenAI configuration. You can get an API key from': + '請輸入您的 OpenAI 配置。您可以從以下地址獲取 API 密鑰:', + 'API Key:': 'API 密鑰:', + 'Invalid credentials: {{errorMessage}}': '憑據無效:{{errorMessage}}', + 'Failed to validate credentials': '驗證憑據失敗', + 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel': + '按 Enter 繼續,Tab/↑↓ 導航,Esc 取消', + 'Select Model': '選擇模型', + '(Press Esc to close)': '(按 Esc 關閉)', + 'Current (effective) configuration': '當前(實際生效)配置', + AuthType: '認證方式', + 'API Key': 'API 密鑰', + unset: '未設置', + '(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.6 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.6 Plus — 高效混合架構,編程性能業界領先', + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': + '來自阿里雲 ModelStudio 的最新 Qwen Vision 模型(版本:qwen3-vl-plus-2025-09-23)', + 'Manage folder trust settings': '管理文件夾信任設置', + 'Manage permission rules': '管理權限規則', + Allow: '允許', + Ask: '詢問', + Deny: '拒絕', + Workspace: '工作區', + "Qwen Code won't ask before using allowed tools.": + 'Qwen Code 使用已允許的工具前不會詢問。', + 'Qwen Code will ask before using these tools.': + 'Qwen Code 使用這些工具前會先詢問。', + 'Qwen Code is not allowed to use denied tools.': + 'Qwen Code 不允許使用被拒絕的工具。', + 'Manage trusted directories for this workspace.': + '管理此工作區的受信任目錄。', + 'Any use of the {{tool}} tool': '{{tool}} 工具的任何使用', + "{{tool}} commands matching '{{pattern}}'": + "匹配 '{{pattern}}' 的 {{tool}} 命令", + 'From user settings': '來自用戶設置', + 'From project settings': '來自項目設置', + 'From session': '來自會話', + 'Project settings (local)': '項目設置(本地)', + 'Saved in .qwen/settings.local.json': '保存在 .qwen/settings.local.json', + 'Project settings': '項目設置', + 'Checked in at .qwen/settings.json': '保存在 .qwen/settings.json', + 'User settings': '用戶設置', + 'Saved in at ~/.qwen/settings.json': '保存在 ~/.qwen/settings.json', + 'Add a new rule…': '添加新規則…', + 'Add {{type}} permission rule': '添加{{type}}權限規則', + 'Permission rules are a tool name, optionally followed by a specifier in parentheses.': + '權限規則是一個工具名稱,可選地後跟括號中的限定符。', + 'e.g.,': '例如', + or: '或', + 'Enter permission rule…': '輸入權限規則…', + 'Enter to submit · Esc to cancel': '回車提交 · Esc 取消', + 'Where should this rule be saved?': '此規則應保存在哪裏?', + 'Enter to confirm · Esc to cancel': '回車確認 · Esc 取消', + 'Delete {{type}} rule?': '刪除{{type}}規則?', + 'Are you sure you want to delete this permission rule?': + '確定要刪除此權限規則嗎?', + 'Permissions:': '權限:', + '(←/→ or tab to cycle)': '(←/→ 或 tab 切換)', + 'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel': + '按 ↑↓ 導航 · 回車選擇 · 輸入搜索 · Esc 取消', + 'Search…': '搜索…', + 'Use /trust to manage folder trust settings for this workspace.': + '使用 /trust 管理此工作區的文件夾信任設置。', + 'Add directory…': '添加目錄…', + 'Add directory to workspace': '添加工作區目錄', + 'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.': + 'Qwen Code 可以讀取工作區中的文件,並在自動接受編輯模式開啓時進行編輯。', + 'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.': + 'Qwen Code 將能夠讀取此目錄中的文件,並在自動接受編輯模式開啓時進行編輯。', + 'Enter the path to the directory:': '輸入目錄路徑:', + 'Enter directory path…': '輸入目錄路徑…', + 'Tab to complete · Enter to add · Esc to cancel': + 'Tab 補全 · 回車添加 · Esc 取消', + 'Remove directory?': '刪除目錄?', + 'Are you sure you want to remove this directory from the workspace?': + '確定要將此目錄從工作區中移除嗎?', + ' (Original working directory)': ' (原始工作目錄)', + ' (from settings)': ' (來自設置)', + 'Directory does not exist.': '目錄不存在。', + 'Path is not a directory.': '路徑不是目錄。', + 'This directory is already in the workspace.': '此目錄已在工作區中。', + 'Already covered by existing directory: {{dir}}': '已被現有目錄覆蓋:{{dir}}', + 'Using:': '已加載: ', + '{{count}} open file': '{{count}} 個打開的文件', + '{{count}} open files': '{{count}} 個打開的文件', + '(ctrl+g to view)': '(按 ctrl+g 查看)', + '{{count}} {{name}} file': '{{count}} 個 {{name}} 文件', + '{{count}} {{name}} files': '{{count}} 個 {{name}} 文件', + '{{count}} MCP server': '{{count}} 個 MCP 服務器', + '{{count}} MCP servers': '{{count}} 個 MCP 服務器', + '{{count}} Blocked': '{{count}} 個已阻止', + '(ctrl+t to view)': '(按 ctrl+t 查看)', + '(ctrl+t to toggle)': '(按 ctrl+t 切換)', + 'Press Ctrl+C again to exit.': '再次按 Ctrl+C 退出', + 'Press Ctrl+D again to exit.': '再次按 Ctrl+D 退出', + 'Press Esc again to clear.': '再次按 Esc 清除', + 'Press ↑ to edit queued messages': '按 ↑ 編輯排隊消息', + 'No MCP servers configured.': '未配置 MCP 服務器', + '⏳ MCP servers are starting up ({{count}} initializing)...': + '⏳ MCP 服務器正在啓動({{count}} 個正在初始化)...', + 'Note: First startup may take longer. Tool availability will update automatically.': + '注意:首次啓動可能需要更長時間。工具可用性將自動更新', + 'Configured MCP servers:': '已配置的 MCP 服務器:', + Ready: '就緒', + 'Starting... (first startup may take longer)': + '正在啓動...(首次啓動可能需要更長時間)', + Disconnected: '已斷開連接', + '{{count}} tool': '{{count}} 個工具', + '{{count}} tools': '{{count}} 個工具', + '{{count}} prompt': '{{count}} 個提示', + '{{count}} prompts': '{{count}} 個提示', + '(from {{extensionName}})': '(來自 {{extensionName}})', + OAuth: 'OAuth', + 'OAuth expired': 'OAuth 已過期', + 'OAuth not authenticated': 'OAuth 未認證', + 'tools and prompts will appear when ready': '工具和提示將在就緒時顯示', + '{{count}} tools cached': '{{count}} 個工具已緩存', + 'Tools:': '工具:', + 'Parameters:': '參數:', + 'Prompts:': '提示:', + Blocked: '已阻止', + '💡 Tips:': '💡 提示:', + Use: '使用', + 'to show server and tool descriptions': '顯示服務器和工具描述', + 'to show tool parameter schemas': '顯示工具參數架構', + 'to hide descriptions': '隱藏描述', + 'to authenticate with OAuth-enabled servers': + '使用支持 OAuth 的服務器進行認證', + Press: '按', + 'to toggle tool descriptions on/off': '切換工具描述開關', + "Starting OAuth authentication for MCP server '{{name}}'...": + "正在爲 MCP 服務器 '{{name}}' 啓動 OAuth 認證...", + 'Restarting MCP servers...': '正在重啓 MCP 服務器...', + '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 中使用 ! 可運行任意 shell 命令(例如 !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,從聊天記錄中生成個性化洞察。', + 'Press Ctrl+O to toggle compact mode — hide tool output and thinking for a cleaner view.': + '按 Ctrl+O 切換緊湊模式 ── 隱藏工具輸出和思考過程,界面更簡潔。', + 'Add a QWEN.md file to give Qwen Code persistent project context.': + '添加 QWEN.md 文件,爲 Qwen Code 提供持久的項目上下文。', + 'Use /btw to ask a quick side question without disrupting the conversation.': + '用 /btw 快速問一個小問題,不會打斷當前對話。', + 'Context is almost full! Run /compress now or start /new to continue.': + '上下文即將用滿!請立即執行 /compress 或使用 /new 開啓新會話。', + 'Context is getting full. Use /compress to free up space.': + '上下文空間不足,用 /compress 釋放空間。', + 'Long conversation? /compress summarizes history to free context.': + '對話太長?用 /compress 總結歷史,釋放上下文。', + 'Agent powering down. Goodbye!': 'Qwen Code 正在關閉,再見!', + 'To continue this session, run': '要繼續此會話,請運行', + 'Interaction Summary': '交互摘要', + 'Session ID:': '會話 ID:', + 'Tool Calls:': '工具調用:', + 'Success Rate:': '成功率:', + 'User Agreement:': '用戶同意率:', + reviewed: '已審覈', + 'Code Changes:': '代碼變更:', + Performance: '性能', + 'Wall Time:': '總耗時:', + 'Agent Active:': '智能體活躍時間:', + 'API Time:': 'API 時間:', + 'Tool Time:': '工具時間:', + 'Session Stats': '會話統計', + 'Model Usage': '模型使用情況', + Reqs: '請求數', + 'Input Tokens': '輸入 token 數', + 'Output Tokens': '輸出 token 數', + 'Savings Highlight:': '節省亮點:', + 'of input tokens were served from the cache, reducing costs.': + '從緩存載入 token ,降低了成本', + 'Tip: For a full token breakdown, run `/stats model`.': + '提示:要查看完整的令牌明細,請運行 `/stats model`', + 'Model Stats For Nerds': '模型統計(技術細節)', + 'Tool Stats For Nerds': '工具統計(技術細節)', + Metric: '指標', + API: 'API', + Requests: '請求數', + Errors: '錯誤數', + 'Avg Latency': '平均延遲', + Tokens: '令牌', + Total: '總計', + Prompt: '提示', + Cached: '緩存', + Thoughts: '思考', + Tool: '工具', + Output: '輸出', + 'No API calls have been made in this session.': + '本次會話中未進行任何 API 調用', + 'Tool Name': '工具名稱', + Calls: '調用次數', + 'Success Rate': '成功率', + 'Avg Duration': '平均耗時', + 'User Decision Summary': '用戶決策摘要', + 'Total Reviewed Suggestions:': '已審覈建議總數:', + ' » Accepted:': ' » 已接受:', + ' » Rejected:': ' » 已拒絕:', + ' » Modified:': ' » 已修改:', + ' Overall Agreement Rate:': ' 總體同意率:', + 'No tool calls have been made in this session.': + '本次會話中未進行任何工具調用', + 'Session start time is unavailable, cannot calculate stats.': + '會話開始時間不可用,無法計算統計信息', + 'Command Format Migration': '命令格式遷移', + 'Found {{count}} TOML command file:': '發現 {{count}} 個 TOML 命令文件:', + 'Found {{count}} TOML command files:': '發現 {{count}} 個 TOML 命令文件:', + '... and {{count}} more': '... 以及其他 {{count}} 個', + 'The TOML format is deprecated. Would you like to migrate them to Markdown format?': + 'TOML 格式已棄用。是否將它們遷移到 Markdown 格式?', + '(Backups will be created and original files will be preserved)': + '(將創建備份,原始文件將保留)', + 'Waiting for user confirmation...': '等待用戶確認...', + '(esc to cancel, {{time}})': '(按 esc 取消,{{time}})', + WITTY_LOADING_PHRASES: [ + '正在努力搬磚,請稍候...', + '老闆在身後,快加載啊!', + '頭髮掉光前,一定能加載完...', + '服務器正在深呼吸,準備放大招...', + '正在向服務器投餵咖啡...', + '正在賦能全鏈路,尋找關鍵抓手...', + '正在降本增效,優化加載路徑...', + '正在打破部門壁壘,沉澱方法論...', + '正在擁抱變化,迭代核心價值...', + '正在對齊顆粒度,打磨底層邏輯...', + '大力出奇跡,正在強行加載...', + '只要我不寫代碼,代碼就沒有 Bug...', + '正在把 Bug 轉化爲 Feature...', + '只要我不尷尬,Bug 就追不上我...', + '正在試圖理解去年的自己寫了什麼...', + '正在猿力覺醒中,請耐心等待...', + '正在詢問產品經理:這需求是真的嗎?', + '正在給產品經理畫餅,請稍等...', + '每一行代碼,都在努力讓世界變得更好一點點...', + '每一個偉大的想法,都值得這份耐心的等待...', + '別急,美好的事物總是需要一點時間去醞釀...', + '願你的代碼永無 Bug,願你的夢想終將成真...', + '哪怕只有 0.1% 的進度,也是在向目標靠近...', + '加載的是字節,承載的是對技術的熱愛...', + ], + 'Enter value...': '請輸入值...', + 'Enter sensitive value...': '請輸入敏感值...', + 'Press Enter to submit, Escape to cancel': '按 Enter 提交,Escape 取消', + 'Markdown file already exists: {{filename}}': + 'Markdown 文件已存在:{{filename}}', + 'TOML Command Format Deprecation Notice': 'TOML 命令格式棄用通知', + 'Found {{count}} command file(s) in TOML format:': + '發現 {{count}} 個 TOML 格式的命令文件:', + 'The TOML format for commands is being deprecated in favor of Markdown format.': + '命令的 TOML 格式正在被棄用,推薦使用 Markdown 格式。', + 'Markdown format is more readable and easier to edit.': + 'Markdown 格式更易讀、更易編輯。', + 'You can migrate these files automatically using:': + '您可以使用以下命令自動遷移這些文件:', + 'Or manually convert each file:': '或手動轉換每個文件:', + 'TOML: prompt = "..." / description = "..."': + 'TOML:prompt = "..." / description = "..."', + 'Markdown: YAML frontmatter + content': 'Markdown:YAML frontmatter + 內容', + 'The migration tool will:': '遷移工具將:', + 'Convert TOML files to Markdown': '將 TOML 文件轉換爲 Markdown', + 'Create backups of original files': '創建原始文件的備份', + 'Preserve all command functionality': '保留所有命令功能', + 'TOML format will continue to work for now, but migration is recommended.': + 'TOML 格式目前仍可使用,但建議遷移。', + 'Open extensions page in your browser': '在瀏覽器中打開擴展市場頁面', + 'Unknown extensions source: {{source}}.': '未知的擴展來源:{{source}}。', + 'Would open extensions page in your browser: {{url}} (skipped in test environment)': + '將在瀏覽器中打開擴展頁面:{{url}}(測試環境中已跳過)', + 'View available extensions at {{url}}': '在 {{url}} 查看可用擴展', + 'Opening extensions page in your browser: {{url}}': + '正在瀏覽器中打開擴展頁面:{{url}}', + 'Failed to open browser. Check out the extensions gallery at {{url}}': + '打開瀏覽器失敗。請訪問擴展市場:{{url}}', + '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': '重試上一次請求', + 'API key cannot be empty.': '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 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.': + '未找到 Coding Plan API Key。請重新通過 Coding Plan 認證。', + 'Failed to update Coding Plan configuration: {{message}}': + '更新 Coding Plan 配置失敗:{{message}}', + 'You can configure your API key and models in settings.json': + '您可以在 settings.json 中配置 API Key 和模型', + 'Refer to the documentation for setup instructions': '請參考文檔瞭解配置說明', + 'Coding Plan': 'Coding Plan', + "Paste your api key of ModelStudio Coding Plan 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', + 'New model configurations are available for {{region}}. Update now?': + '{{region}} 有新的模型配置可用。是否立即更新?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}} 配置更新成功。模型已切換至 "{{model}}"。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + '成功通過 {{region}} 認證。API Key 和模型配置已保存至 settings.json(已備份)。', + 'Context Usage': '上下文使用情況', + '% used': '% 已用', + '% context used': '% 上下文已用', + 'Context exceeds limit! Use /compress or /clear to reduce.': + '上下文超出限制!請使用 /compress 或 /clear 來減少上下文。', + 'Use /compress or /clear': '使用 /compress 或 /clear', + 'No API response yet. Send a message to see actual usage.': + '暫無 API 響應。發送消息以查看實際使用情況。', + 'Estimated pre-conversation overhead': '預估對話前開銷', + 'Context window': '上下文窗口', + tokens: 'tokens', + Used: '已用', + Free: '空閒', + 'Autocompact buffer': '自動壓縮緩衝區', + 'Usage by category': '分類用量', + 'System prompt': '系統提示', + 'Built-in tools': '內置工具', + 'MCP tools': 'MCP 工具', + 'Memory files': '記憶文件', + Skills: '技能', + Messages: '消息', + 'Show context window usage breakdown.': '顯示上下文窗口使用情況分解。', + 'Run /context detail for per-item breakdown.': + '運行 /context detail 查看詳細分解。', + 'Show context window usage breakdown. Use "/context detail" for per-item breakdown.': + '顯示上下文窗口使用情況分解。輸入 "/context detail" 查看詳細分解。', + 'body loaded': '內容已加載', + memory: '記憶', + '{{region}} configuration updated successfully.': '{{region}} 配置更新成功。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + '成功通過 {{region}} 認證。API Key 和模型配置已保存至 settings.json。', + 'Tip: Use /model to switch between available Coding Plan models.': + '提示:使用 /model 切換可用的 Coding Plan 模型。', + 'Please answer the following question(s):': '請回答以下問題:', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + '無法在非交互模式下詢問用戶問題。請在交互模式下運行以使用此工具。', + 'User declined to answer the questions.': '用戶拒絕回答問題。', + 'User has provided the following answers:': '用戶提供了以下答案:', + 'Failed to process user answers:': '處理用戶答案失敗:', + 'Type something...': '輸入內容...', + Submit: '提交', + 'Submit answers': '提交答案', + Cancel: '取消', + 'Your answers:': '您的答案:', + '(not answered)': '(未回答)', + 'Ready to submit your answers?': '準備好提交您的答案了嗎?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: 導航 | ←/→: 切換標籤頁 | Enter: 選擇', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: 導航 | ←/→: 切換標籤頁 | Space/Enter: 切換 | Esc: 取消', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: 導航 | Space/Enter: 切換 | Esc: 取消', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: 導航 | Enter: 選擇 | Esc: 取消', + 'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan': + '使用 Qwen OAuth 或阿里雲百鍊 Coding Plan 配置 Qwen 認證信息', + 'Authenticate using Qwen OAuth': '使用 Qwen OAuth 進行認證', + 'Authenticate using Alibaba Cloud Coding Plan': + '使用阿里雲百鍊 Coding Plan 進行認證', + 'Region for Coding Plan (china/global)': 'Coding Plan 區域 (china/global)', + 'API key for Coding Plan': 'Coding Plan 的 API 密鑰', + 'Show current authentication status': '顯示當前認證狀態', + 'Authentication completed successfully.': '認證完成。', + 'Starting Qwen OAuth authentication...': '正在啓動 Qwen OAuth 認證...', + 'Successfully authenticated with Qwen OAuth.': '已成功通過 Qwen OAuth 認證。', + 'Failed to authenticate with Qwen OAuth: {{error}}': + 'Qwen OAuth 認證失敗:{{error}}', + 'Processing Alibaba Cloud Coding Plan authentication...': + '正在處理阿里雲百鍊 Coding Plan 認證...', + 'Successfully authenticated with Alibaba Cloud Coding Plan.': + '已成功通過阿里雲百鍊 Coding Plan 認證。', + 'Failed to authenticate with Coding Plan: {{error}}': + 'Coding Plan 認證失敗:{{error}}', + '中国 (China)': '中國 (China)', + '阿里云百炼 (aliyun.com)': '阿里雲百鍊 (aliyun.com)', + Global: '全球', + 'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)', + 'Select region for Coding Plan:': '選擇 Coding Plan 區域:', + 'Enter your Coding Plan API key: ': '請輸入您的 Coding Plan API 密鑰:', + 'Select authentication method:': '選擇認證方式:', + '\n=== Authentication Status ===\n': '\n=== 認證狀態 ===\n', + '⚠️ No authentication method configured.\n': '⚠️ 未配置認證方式。\n', + 'Run one of the following commands to get started:\n': + '運行以下命令之一開始配置:\n', + ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (discontinued)': + ' qwen auth qwen-oauth - 使用 Qwen OAuth 登錄(已停用)', + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth coding-plan - 使用阿里雲百鍊 Coding Plan 認證\n', + 'Or simply run:': '或者直接運行:', + ' qwen auth - Interactive authentication setup\n': + ' qwen auth - 交互式認證配置\n', + '✓ Authentication Method: Qwen OAuth': '✓ 認證方式:Qwen OAuth', + ' Type: Free tier (discontinued 2026-04-15)': + ' 類型:免費額度(2026-04-15 已停用)', + ' Limit: No longer available': ' 限額:已不可用', + 'Qwen OAuth free tier was discontinued on 2026-04-15. Run /auth to switch to Coding Plan, OpenRouter, Fireworks AI, or another provider.': + 'Qwen OAuth 免費額度已於 2026-04-15 停用。請運行 /auth 切換到 Coding Plan、OpenRouter、Fireworks AI 或其他服務商。', + ' Models: Qwen latest models\n': ' 模型:Qwen 最新模型\n', + '✓ Authentication Method: Alibaba Cloud Coding Plan': + '✓ 認證方式:阿里雲百鍊 Coding Plan', + '中国 (China) - 阿里云百炼': '中國 (China) - 阿里雲百鍊', + 'Global - Alibaba Cloud': '全球 - Alibaba Cloud', + ' Region: {{region}}': ' 區域:{{region}}', + ' Current Model: {{model}}': ' 當前模型:{{model}}', + ' Config Version: {{version}}': ' 配置版本:{{version}}', + ' Status: API key configured\n': ' 狀態:API 密鑰已配置\n', + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)': + '⚠️ 認證方式:阿里雲百鍊 Coding Plan(不完整)', + ' Issue: API key not found in environment or settings\n': + ' 問題:在環境變量或設置中未找到 API 密鑰\n', + ' Run `qwen auth coding-plan` to re-configure.\n': + ' 運行 `qwen auth coding-plan` 重新配置。\n', + '✓ Authentication Method: {{type}}': '✓ 認證方式:{{type}}', + ' Status: Configured\n': ' 狀態:已配置\n', + 'Failed to check authentication status: {{error}}': + '檢查認證狀態失敗:{{error}}', + 'Select an option:': '請選擇:', + 'Raw mode not available. Please run in an interactive terminal.': + '原始模式不可用。請在交互式終端中運行。', + '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': + '(使用 ↑ ↓ 箭頭導航,Enter 選擇,Ctrl+C 退出)\n', + compact: '緊湊', + 'compact mode: on (Ctrl+O off)': '緊湊模式:開(Ctrl+O 關閉)', + 'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).': + '緊湊模式下隱藏工具輸出和思考過程,界面更簡潔(Ctrl+O 切換)。', + 'Press Ctrl+O to show full tool output': '按 Ctrl+O 查看詳細工具調用結果', + 'Switch to plan mode or exit plan mode': '切換到計劃模式或退出計劃模式', + 'Exited plan mode. Previous approval mode restored.': + '已退出計劃模式,已恢復之前的審批模式。', + 'Enabled plan mode. The agent will analyze and plan without executing tools.': + '啓用計劃模式。智能體將只分析和規劃,而不執行工具。', + 'Already in plan mode. Use "/plan exit" to exit plan mode.': + '已處於計劃模式。使用 "/plan exit" 退出計劃模式。', + 'Not in plan mode. Use "/plan" to enter plan mode first.': + '未處於計劃模式。請先使用 "/plan" 進入計劃模式。', + "Set up Qwen Code's status line UI": '配置 Qwen Code 的狀態欄', +}; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index a1e7df51a..4c3c98ba6 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -578,6 +578,7 @@ export default { '(user)': '(用户)', '[not set]': '[未设置]', '[value stored in keychain]': '[值存储在钥匙串中]', + 'Value:': '值:', 'Manage extension settings.': '管理扩展设置。', 'You need to specify a command (set or list).': '您需要指定命令(set 或 list)。', @@ -1037,6 +1038,8 @@ export default { 'Command:': '命令:', 'Working Directory:': '工作目录:', 'Capabilities:': '功能:', + 'No server selected': '未选择服务器', + prompts: '提示', // MCP Tool List 'No tools available for this server.': '此服务器没有可用工具。', @@ -1049,7 +1052,9 @@ export default { '{{current}}/{{total}}': '{{current}}/{{total}}', // MCP Tool Detail + required: '必需', Type: '类型', + Enum: '枚举', Parameters: '参数', 'No tool selected': '未选择工具', Annotations: '注解', diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index e2988b612..13179abcc 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -28,6 +28,8 @@ import { vi, type Mock, type MockInstance } from 'vitest'; import type { LoadedSettings } from './config/settings.js'; import { CommandKind, type ExecutionMode } from './ui/commands/types.js'; import { filterCommandsForMode } from './services/commandUtils.js'; +import { _resetCleanupFunctionsForTest } from './utils/cleanup.js'; +import { _resetExitLatchForTest } from './utils/errors.js'; // Mock core modules vi.mock('./ui/hooks/atCommandProcessor.js'); @@ -79,6 +81,12 @@ describe('runNonInteractive', () => { let mockGetDebugResponses: Mock; beforeEach(async () => { + // Reset module-level state from any prior test in this file. Without + // these resets the once-set exit latch parks subsequent JSON-mode + // handleError tests in the never-resolving promise (5s vitest timeout). + _resetCleanupFunctionsForTest(); + _resetExitLatchForTest(); + mockCoreExecuteToolCall = vi.mocked(executeToolCall); mockShutdownTelemetry = vi.mocked(shutdownTelemetry); mockGetCommandsForMode.mockImplementation((mode: ExecutionMode) => @@ -275,6 +283,38 @@ describe('runNonInteractive', () => { expect(mockShutdownTelemetry).toHaveBeenCalled(); }); + it('on EPIPE, destroys stdout and returns normally instead of process.exit', async () => { + // Regression: process.exit(0) on EPIPE bypassed runExitCleanup → flush() + // and dropped queued JSONL writes for `qwen -p ... | head -1` patterns. + // process.exit is mocked to throw in beforeEach, so reaching the + // assertion also proves the bypass route is gone. + setupMetricsMock(); + const stdoutDestroySpy = vi + .spyOn(process.stdout, 'destroy') + .mockReturnValue(process.stdout); + + mockGeminiClient.sendMessageStream.mockImplementation( + async function* mockStream(): AsyncGenerator { + process.stdout.emit( + 'error', + Object.assign(new Error('EPIPE'), { code: 'EPIPE' }), + ); + yield { type: GeminiEventType.Content, value: 'Hello' }; + yield { + type: GeminiEventType.Finished, + value: { + reason: undefined, + usageMetadata: { totalTokenCount: 0 }, + }, + }; + }, + ); + + await runNonInteractive(mockConfig, mockSettings, 'test', 'p1'); + + expect(stdoutDestroySpy).toHaveBeenCalled(); + }); + it('should handle a single tool call and respond', async () => { setupMetricsMock(); const toolCallEvent: ServerGeminiStreamEvent = { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 15b47c12a..01473dfb3 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -175,16 +175,22 @@ export async function runNonInteractive( let totalApiDurationMs = 0; const startTime = Date.now(); - const stdoutErrorHandler = (err: NodeJS.ErrnoException) => { - if (err.code === 'EPIPE') { - process.stdout.removeListener('error', stdoutErrorHandler); - process.exit(0); - } - }; - const geminiClient = config.getGeminiClient(); const abortController = options.abortController ?? new AbortController(); + // EPIPE: don't process.exit here — that bypasses the caller's + // runExitCleanup → flush() and drops queued JSONL writes. Destroy + // stdout instead and let the natural return drive cleanup. (Aborting + // is also wrong: the abort path runs handleCancellationError → exit + // 130 and re-introduces the same bypass.) + let pipeBroken = false; + const stdoutErrorHandler = (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE' && !pipeBroken) { + pipeBroken = true; + process.stdout.destroy(); + } + }; + // Setup signal handlers for graceful shutdown const shutdownHandler = () => { debugLogger.debug('[runNonInteractive] Shutdown signal received'); @@ -343,7 +349,7 @@ export async function runNonInteractive( config.getMaxSessionTurns() >= 0 && turnCount > config.getMaxSessionTurns() ) { - handleMaxTurnsExceededError(config); + await handleMaxTurnsExceededError(config); } const toolCallRequests: ToolCallRequestInfo[] = []; @@ -366,7 +372,7 @@ export async function runNonInteractive( for await (const event of responseStream) { if (abortController.signal.aborted) { - handleCancellationError(config); + await handleCancellationError(config); } // Use adapter for all event processing adapter.processEvent(event); @@ -492,7 +498,7 @@ export async function runNonInteractive( config.getMaxSessionTurns() >= 0 && turnCount > config.getMaxSessionTurns() ) { - handleMaxTurnsExceededError(config); + await handleMaxTurnsExceededError(config); } const inputFormat = @@ -705,7 +711,7 @@ export async function runNonInteractive( while (localQueue.length > 0) { emitNotificationToSdk(localQueue.shift()!); } - handleCancellationError(config); + await handleCancellationError(config); } await drainLocalQueue(); // Wait for every agent's terminal notification, not just the @@ -764,7 +770,7 @@ export async function runNonInteractive( usage, stats, }); - handleError(error, config); + await handleError(error, config); } finally { const reg = config.getBackgroundTaskRegistry(); reg.setNotificationCallback(undefined); diff --git a/packages/cli/src/nonInteractiveCliCommands.test.ts b/packages/cli/src/nonInteractiveCliCommands.test.ts index 8e40b633d..5d7759db7 100644 --- a/packages/cli/src/nonInteractiveCliCommands.test.ts +++ b/packages/cli/src/nonInteractiveCliCommands.test.ts @@ -5,7 +5,10 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { handleSlashCommand } from './nonInteractiveCliCommands.js'; +import { + getAvailableCommands, + handleSlashCommand, +} from './nonInteractiveCliCommands.js'; import type { Config } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from './config/settings.js'; import { CommandKind, type ExecutionMode } from './ui/commands/types.js'; @@ -340,3 +343,43 @@ describe('handleSlashCommand', () => { }); }); }); + +describe('getAvailableCommands', () => { + let mockConfig: Config; + + beforeEach(() => { + mockCommandServiceCreate.mockResolvedValue({ + getCommands: mockGetCommands, + getCommandsForMode: mockGetCommandsForMode, + }); + + mockConfig = { + getExperimentalZedIntegration: vi.fn().mockReturnValue(false), + isInteractive: vi.fn().mockReturnValue(false), + getSessionId: vi.fn().mockReturnValue('test-session'), + getFolderTrustFeature: vi.fn().mockReturnValue(false), + getFolderTrust: vi.fn().mockReturnValue(false), + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + getDisabledSlashCommands: vi.fn().mockReturnValue([]), + storage: {}, + } as unknown as Config; + }); + + it('includes /export in the default non-interactive command list', async () => { + mockGetCommandsForMode.mockReturnValue([ + { + name: 'export', + description: 'Export current session', + kind: CommandKind.BUILT_IN, + action: vi.fn(), + }, + ]); + + const commands = await getAvailableCommands( + mockConfig, + new AbortController().signal, + ); + + expect(commands.map((command) => command.name)).toContain('export'); + }); +}); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index cdde266b8..9d7fd4722 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -45,6 +45,7 @@ import { recapCommand } from '../ui/commands/recapCommand.js'; import { renameCommand } from '../ui/commands/renameCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { resumeCommand } from '../ui/commands/resumeCommand.js'; +import { rewindCommand } from '../ui/commands/rewindCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; import { skillsCommand } from '../ui/commands/skillsCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; @@ -126,6 +127,7 @@ export class BuiltinCommandLoader implements ICommandLoader { renameCommand, restoreCommand(this.config), resumeCommand, + rewindCommand, skillsCommand, statsCommand, summaryCommand, diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 8df422f4b..7dcc98d90 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -57,6 +57,7 @@ describe('App', () => { streamingState: StreamingState.Idle, quittingMessages: null, dialogsVisible: false, + stickyTodos: null, mainControlsRef: { current: null }, historyManager: { addItem: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 53da7cc32..0ecd6dd6c 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -14,7 +14,8 @@ import { type Mock, } from 'vitest'; import { render, cleanup } from 'ink-testing-library'; -import { AppContainer } from './AppContainer.js'; +import { AppContainer, dedupeNewestFirst } from './AppContainer.js'; +import ansiEscapes from 'ansi-escapes'; import { type Config, makeFakeConfig, @@ -28,7 +29,9 @@ import { UIActionsContext, type UIActions, } from './contexts/UIActionsContext.js'; +import { ToolCallStatus } from './types.js'; import { useContext } from 'react'; +import { Box, measureElement } from 'ink'; // Mock useStdout to capture terminal title writes let mockStdout: { write: ReturnType }; @@ -48,7 +51,7 @@ let capturedUIActions: UIActions; function TestContextConsumer() { capturedUIState = useContext(UIStateContext)!; capturedUIActions = useContext(UIActionsContext)!; - return null; + return ; } vi.mock('./App.js', () => ({ @@ -120,7 +123,6 @@ import { useSessionStats } from './contexts/SessionContext.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; -import { measureElement } from 'ink'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { ShellExecutionService } from '@qwen-code/qwen-code-core'; @@ -245,6 +247,7 @@ describe('AppContainer State Management', () => { getQueuedMessagesText: vi.fn().mockReturnValue(''), popAllMessages: vi.fn().mockReturnValue(null), drainQueue: vi.fn().mockReturnValue([]), + popNextSegment: vi.fn().mockReturnValue(null), }); mockedUseAutoAcceptIndicator.mockReturnValue(false); mockedUseGitBranchName.mockReturnValue('main'); @@ -426,6 +429,83 @@ describe('AppContainer State Management', () => { }).not.toThrow(); }); + it('refreshStatic clears the terminal before remounting history', () => { + render( + , + ); + + capturedUIActions.refreshStatic(); + + expect(mockStdout.write).toHaveBeenCalledWith(ansiEscapes.clearTerminal); + }); + + it('handleClearScreen avoids a second clearTerminal write', () => { + const clearSpy = vi.spyOn(console, 'clear').mockImplementation(() => {}); + + render( + , + ); + + capturedUIActions.handleClearScreen(); + + expect(clearSpy).toHaveBeenCalledTimes(1); + expect(mockStdout.write).not.toHaveBeenCalledWith( + ansiEscapes.clearTerminal, + ); + + clearSpy.mockRestore(); + }); + + it('passes a remount-only refresh callback to slash commands', () => { + let slashRefreshStatic: (() => void) | undefined; + mockedUseSlashCommandProcessor.mockImplementation( + ( + _config, + _settings, + _addItem, + _clearItems, + _loadHistory, + refreshStatic, + ) => { + slashRefreshStatic = refreshStatic; + return { + handleSlashCommand: vi.fn(), + slashCommands: [], + pendingHistoryItems: [], + commandContext: {}, + shellConfirmationRequest: null, + confirmationRequest: null, + }; + }, + ); + + render( + , + ); + + slashRefreshStatic?.(); + + expect(slashRefreshStatic).toBeDefined(); + expect(mockStdout.write).not.toHaveBeenCalledWith( + ansiEscapes.clearTerminal, + ); + }); + it('provides ConfigContext with config object', () => { expect(() => { render( @@ -459,6 +539,7 @@ describe('AppContainer State Management', () => { getQueuedMessagesText: vi.fn().mockReturnValue(''), popAllMessages: vi.fn().mockReturnValue(null), drainQueue: vi.fn().mockReturnValue([]), + popNextSegment: vi.fn().mockReturnValue(null), }); render( @@ -476,6 +557,44 @@ describe('AppContainer State Management', () => { expect(mockQueueMessage).not.toHaveBeenCalled(); }); + it('submits slash commands immediately instead of queueing while idle', () => { + const mockSubmitQuery = vi.fn(); + const mockQueueMessage = vi.fn(); + + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'idle', + submitQuery: mockSubmitQuery, + initError: null, + pendingHistoryItems: [], + thought: null, + cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), + }); + mockedUseMessageQueue.mockReturnValue({ + messageQueue: [], + addMessage: mockQueueMessage, + clearQueue: vi.fn(), + getQueuedMessagesText: vi.fn().mockReturnValue(''), + popAllMessages: vi.fn().mockReturnValue(null), + drainQueue: vi.fn().mockReturnValue([]), + popNextSegment: vi.fn().mockReturnValue(null), + }); + + render( + , + ); + + capturedUIActions.handleFinalSubmit('/model'); + + expect(mockSubmitQuery).toHaveBeenCalledWith('/model'); + expect(mockQueueMessage).not.toHaveBeenCalled(); + }); + it.each(['exit', 'quit', ':q', ':q!', ':wq', ':wq!'])( 'routes bare "%s" to /quit instead of sending as a message', (command) => { @@ -497,6 +616,7 @@ describe('AppContainer State Management', () => { getQueuedMessagesText: vi.fn().mockReturnValue(''), popAllMessages: vi.fn().mockReturnValue(null), drainQueue: vi.fn().mockReturnValue([]), + popNextSegment: vi.fn().mockReturnValue(null), }); render( @@ -577,6 +697,7 @@ describe('AppContainer State Management', () => { getQueuedMessagesText: vi.fn().mockReturnValue(''), popAllMessages: vi.fn().mockReturnValue(null), drainQueue: vi.fn().mockReturnValue([]), + popNextSegment: vi.fn().mockReturnValue(null), }); render( @@ -605,6 +726,7 @@ describe('AppContainer State Management', () => { it('moves queued follow-up messages into an empty buffer on cancel', async () => { const mockSetText = vi.fn(); const mockPopAllMessages = vi.fn().mockReturnValue('queued follow-up'); + const mockClearQueue = vi.fn(); mockedUseTextBuffer.mockReturnValue({ text: '', setText: mockSetText, @@ -626,10 +748,11 @@ describe('AppContainer State Management', () => { mockedUseMessageQueue.mockReturnValue({ messageQueue: ['queued follow-up'], addMessage: vi.fn(), - clearQueue: vi.fn(), + clearQueue: mockClearQueue, getQueuedMessagesText: vi.fn().mockReturnValue('queued follow-up'), popAllMessages: mockPopAllMessages, drainQueue: vi.fn().mockReturnValue(['queued follow-up']), + popNextSegment: vi.fn().mockReturnValue('queued follow-up'), }); render( @@ -653,6 +776,75 @@ describe('AppContainer State Management', () => { expect.stringContaining('the previous prompt'), ); expect(mockPopAllMessages).toHaveBeenCalled(); + // popAllForEdit drains the queue internally, so the cancel handler + // does not need to call clearQueue separately on this path. + expect(mockClearQueue).not.toHaveBeenCalled(); + }); + + it('drops the queue when cancelling during tool execution', async () => { + // Simulates: user asks for a shell tool (e.g. sleep 30), queues + // `/model` and `hi` while the tool is running, then hits Ctrl+C. + // The cancel must clear BOTH the buffer and the queue so that + // `hi` does not auto-fire once the tool settles and the app + // returns to idle. + const mockSetText = vi.fn(); + const mockClearQueue = vi.fn(); + mockedUseTextBuffer.mockReturnValue({ + text: '', + setText: mockSetText, + }); + installCancelCapture({ + streamingState: 'responding', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [ + { + callId: 'call-1', + name: 'run_shell_command', + description: 'sleep 30', + status: ToolCallStatus.Executing, + resultDisplay: undefined, + confirmationDetails: undefined, + renderOutputAsMarkdown: false, + }, + ], + }, + ], + thought: null, + cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), + }); + mockedUseMessageQueue.mockReturnValue({ + messageQueue: ['/model', 'hi'], + addMessage: vi.fn(), + clearQueue: mockClearQueue, + getQueuedMessagesText: vi.fn().mockReturnValue('/model\n\nhi'), + popAllMessages: vi.fn().mockReturnValue('/model'), + drainQueue: vi.fn().mockReturnValue([]), + popNextSegment: vi.fn().mockReturnValue('/model'), + }); + + render( + , + ); + + await Promise.resolve(); + await Promise.resolve(); + + triggerCancel(); + + // Buffer cleared and queue dropped — same "abort and redirect" + // contract as the non-tool cancel path. + expect(mockSetText).toHaveBeenCalledWith(''); + expect(mockClearQueue).toHaveBeenCalled(); }); it('preserves an in-progress draft when restoring queued messages on cancel', async () => { @@ -680,6 +872,7 @@ describe('AppContainer State Management', () => { getQueuedMessagesText: vi.fn().mockReturnValue('queued follow-up'), popAllMessages: vi.fn().mockReturnValue('queued follow-up'), drainQueue: vi.fn().mockReturnValue(['queued follow-up']), + popNextSegment: vi.fn().mockReturnValue('queued follow-up'), }); render( @@ -1405,3 +1598,28 @@ describe('AppContainer State Management', () => { }); }); }); + +describe('dedupeNewestFirst', () => { + it('returns empty array for empty input', () => { + expect(dedupeNewestFirst([])).toEqual([]); + }); + + it('preserves order when there are no duplicates', () => { + expect(dedupeNewestFirst(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']); + }); + + it('removes consecutive duplicates', () => { + expect(dedupeNewestFirst(['a', 'a', 'b'])).toEqual(['a', 'b']); + }); + + it('removes non-consecutive duplicates keeping the first (newest) occurrence', () => { + expect( + dedupeNewestFirst([ + 'first prompt', + 'third prompt', + 'second prompt', + 'first prompt', + ]), + ).toEqual(['first prompt', 'third prompt', 'second prompt']); + }); +}); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 51b02abbb..0274acd3a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -58,6 +58,7 @@ import { type WaitingToolCall, } from '@qwen-code/qwen-code-core'; import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js'; +import { getStickyTodos } from './utils/todoSnapshot.js'; import { validateAuthMethod } from '../config/auth.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; import process from 'node:process'; @@ -74,6 +75,11 @@ import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js'; import { useResumeCommand } from './hooks/useResumeCommand.js'; import { useDeleteCommand } from './hooks/useDeleteCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; +import { useDoublePress } from './hooks/useDoublePress.js'; +import { + computeApiTruncationIndex, + isRealUserTurn, +} from './utils/historyMapping.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { CompactModeProvider } from './contexts/CompactModeContext.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; @@ -88,7 +94,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useVim } from './hooks/vim.js'; -import { isBtwCommand } from './utils/commandUtils.js'; +import { isBtwCommand, isSlashCommand } from './utils/commandUtils.js'; import { type LoadedSettings, SettingScope } from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; import { useFocus } from './hooks/useFocus.js'; @@ -158,6 +164,20 @@ function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { }); } +// Exported for tests. Given a newest-first list of messages, return a list +// with duplicates removed, keeping the first (newest) occurrence of each. +export function dedupeNewestFirst(messages: readonly string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const msg of messages) { + if (!seen.has(msg)) { + seen.add(msg); + result.push(msg); + } + } + return result; +} + interface AppContainerProps { config: Config; settings: LoadedSettings; @@ -455,28 +475,27 @@ export const AppContainer = (props: AppContainerProps) => { ) .map((item) => item.text) .reverse(); + // Current-session messages are already newest-first; combining with past + // messages gives a newest-first list. dedupeNewestFirst keeps the first + // (newest) occurrence so resubmitting an old prompt promotes it to + // "most recent" rather than leaving a stale copy at an older position. const combinedMessages = [ ...currentSessionUserMessages, ...pastMessagesRaw, ]; - const deduplicatedMessages: string[] = []; - if (combinedMessages.length > 0) { - deduplicatedMessages.push(combinedMessages[0]); - for (let i = 1; i < combinedMessages.length; i++) { - if (combinedMessages[i] !== combinedMessages[i - 1]) { - deduplicatedMessages.push(combinedMessages[i]); - } - } - } - setUserMessages(deduplicatedMessages.reverse()); + setUserMessages(dedupeNewestFirst(combinedMessages).reverse()); }; fetchUserMessages(); }, [historyManager.history, logger]); + const remountStaticHistory = useCallback(() => { + setHistoryRemountKey((prev) => prev + 1); + }, []); + const refreshStatic = useCallback(() => { stdout.write(ansiEscapes.clearTerminal); - setHistoryRemountKey((prev) => prev + 1); - }, [setHistoryRemountKey, stdout]); + remountStaticHistory(); + }, [remountStaticHistory, stdout]); const { isThemeDialogOpen, @@ -627,6 +646,12 @@ export const AppContainer = (props: AppContainerProps) => { const { isHooksDialogOpen, openHooksDialog, closeHooksDialog } = useHooksDialog(); + // Ref bridge: the guarded openRewindSelector callback is defined later + // (after useDoublePress), but slashCommandActions needs it now. The ref + // lets the useMemo capture a stable function pointer whose implementation + // is swapped in once the real callback exists. + const openRewindSelectorRef = useRef<() => void>(() => {}); + const slashCommandActions = useMemo( () => ({ openAuthDialog, @@ -655,6 +680,7 @@ export const AppContainer = (props: AppContainerProps) => { openMcpDialog, openHooksDialog, openResumeDialog, + openRewindSelector: () => openRewindSelectorRef.current(), handleResume, openDeleteDialog, }), @@ -699,7 +725,7 @@ export const AppContainer = (props: AppContainerProps) => { historyManager.addItem, historyManager.clearItems, historyManager.loadHistory, - refreshStatic, + remountStaticHistory, toggleVimEnabled, isProcessing, setIsProcessing, @@ -879,16 +905,18 @@ export const AppContainer = (props: AppContainerProps) => { disabled: agentViewState.activeView !== 'main', }); - const { messageQueue, addMessage, popAllMessages, drainQueue } = - useMessageQueue({ - isConfigInitialized, - streamingState, - submitQuery, - }); + const { + messageQueue, + addMessage, + clearQueue, + popAllMessages, + drainQueue, + popNextSegment, + } = useMessageQueue(); // Bridge message queue to mid-turn drain via ref. // drainQueue reads the synchronous queueRef inside the hook, so it - // stays consistent with popAllMessages even before React re-renders. + // stays consistent with popNextSegment even before React re-renders. midTurnDrainRef.current = drainQueue; // Connect remote input watcher to submitQuery for bidirectional sync. @@ -1164,6 +1192,14 @@ export const AppContainer = (props: AppContainerProps) => { speculationRef.current = IDLE_SPECULATION; } + if ( + streamingState === StreamingState.Idle && + isSlashCommand(submittedValue) + ) { + void submitQuery(submittedValue); + return; + } + addMessage(submittedValue); }, [ @@ -1200,6 +1236,10 @@ export const AppContainer = (props: AppContainerProps) => { () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], ); + const stickyTodos = useMemo( + () => getStickyTodos(historyManager.history, pendingHistoryItems), + [historyManager.history, pendingHistoryItems], + ); // Terminal tab progress bar (OSC 9;4) for iTerm2/Ghostty useTerminalProgress(streamingState, isToolExecuting(pendingHistoryItems)); @@ -1210,28 +1250,22 @@ export const AppContainer = (props: AppContainerProps) => { ...pendingGeminiHistoryItems, ]; if (isToolExecuting(pendingHistoryItems)) { - buffer.setText(''); // Just clear the prompt + // Tool-cancel: drop both buffer and queue so nothing auto-fires later. + buffer.setText(''); + clearQueue(); return; } - // Move any queued follow-up messages back into the buffer so the user - // can edit or resubmit them. Otherwise leave the buffer alone — in - // particular, do NOT repopulate it with the previous prompt; the user - // can still recall it via history navigation (Up/Ctrl+P). - // - // popAllMessages is atomic via the queue's synchronous ref, matching - // the drain behavior used during tool completion. + // Restore queued input joined into the buffer for editing. const popped = popAllMessages(); if (popped) { const currentText = buffer.text; - // Preserve any in-progress draft the user typed since submitting (this - // is reachable via Ctrl+C cancel, which fires regardless of buffer - // content). Mirrors the popQueueIntoInput convention in InputPrompt. buffer.setText(currentText ? `${popped}\n${currentText}` : popped); } }, [ buffer, popAllMessages, + clearQueue, pendingSlashCommandHistoryItems, pendingGeminiHistoryItems, ]); @@ -1239,8 +1273,8 @@ export const AppContainer = (props: AppContainerProps) => { const handleClearScreen = useCallback(() => { historyManager.clearItems(); clearScreen(); - refreshStatic(); - }, [historyManager, refreshStatic]); + remountStaticHistory(); + }, [historyManager, remountStaticHistory]); const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); @@ -1258,40 +1292,6 @@ export const AppContainer = (props: AppContainerProps) => { (streamingState === StreamingState.Idle || streamingState === StreamingState.Responding); - const [controlsHeight, setControlsHeight] = useState(0); - - useLayoutEffect(() => { - if (mainControlsRef.current) { - const fullFooterMeasurement = measureElement(mainControlsRef.current); - if (fullFooterMeasurement.height > 0) { - setControlsHeight(fullFooterMeasurement.height); - } - } - }, [buffer, terminalWidth, terminalHeight, btwItem]); - - // agentViewState is declared earlier (before handleFinalSubmit) so it - // is available for input routing. Referenced here for layout computation. - - // Compute available terminal height based on controls measurement. - // When in-process agents are present the AgentTabBar renders an extra - // row at the top of the layout; subtract it so downstream consumers - // (shell, transcript, etc.) don't overestimate available space. - const tabBarHeight = agentViewState.agents.size > 0 ? 1 : 0; - const availableTerminalHeight = Math.max( - 0, - terminalHeight - controlsHeight - staticExtraHeight - 2 - tabBarHeight, - ); - - config.setShellExecutionConfig({ - terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), - terminalHeight: Math.max( - Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), - 1, - ), - pager: settings.merged.tools?.shell?.pager, - showColor: settings.merged.tools?.shell?.showColor, - }); - const isFocused = useFocus(); useBracketedPaste(); @@ -1319,16 +1319,6 @@ export const AppContainer = (props: AppContainerProps) => { const initialPrompt = useMemo(() => config.getQuestion(), [config]); const initialPromptSubmitted = useRef(false); - useEffect(() => { - if (activePtyId) { - ShellExecutionService.resizePty( - activePtyId, - Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), - Math.max(Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), 1), - ); - } - }, [terminalWidth, availableTerminalHeight, activePtyId]); - useEffect(() => { if ( initialPrompt && @@ -1523,6 +1513,8 @@ export const AppContainer = (props: AppContainerProps) => { const [escapePressedOnce, setEscapePressedOnce] = useState(false); const escapeTimerRef = useRef(null); const dialogsVisibleRef = useRef(false); + const [isRewindSelectorOpen, setIsRewindSelectorOpen] = useState(false); + const [rewindEscPending, setRewindEscPending] = useState(false); const [constrainHeight, setConstrainHeight] = useState(true); const [ideContextState, setIdeContextState] = useState< IdeContext | undefined @@ -1536,8 +1528,107 @@ export const AppContainer = (props: AppContainerProps) => { needsRestart: ideNeedsRestart, restartReason: ideTrustRestartReason, } = useIdeTrustListener(); + const { + isFeedbackDialogOpen, + openFeedbackDialog, + closeFeedbackDialog, + temporaryCloseFeedbackDialog, + submitFeedback, + } = useFeedbackDialog({ + config, + settings, + streamingState, + history: historyManager.history, + sessionStats, + }); + const dialogsVisible = + showWelcomeBackDialog || + shouldShowIdePrompt || + shouldShowCommandMigrationNudge || + isFolderTrustDialogOpen || + !!shellConfirmationRequest || + !!confirmationRequest || + confirmUpdateExtensionRequests.length > 0 || + !!codingPlanUpdateRequest || + settingInputRequests.length > 0 || + pluginChoiceRequests.length > 0 || + !!loopDetectionConfirmationRequest || + isThemeDialogOpen || + isSettingsDialogOpen || + isMemoryDialogOpen || + isModelDialogOpen || + isTrustDialogOpen || + activeArenaDialog !== null || + isPermissionsDialogOpen || + isAuthDialogOpen || + isAuthenticating || + isEditorDialogOpen || + showIdeRestartPrompt || + isSubagentCreateDialogOpen || + isAgentsManagerDialogOpen || + isMcpDialogOpen || + isHooksDialogOpen || + isApprovalModeDialogOpen || + isResumeDialogOpen || + isDeleteDialogOpen || + isExtensionsManagerDialogOpen || + isRewindSelectorOpen || + bgTasksDialogOpen; + dialogsVisibleRef.current = dialogsVisible; + const shouldShowStickyTodos = + stickyTodos !== null && + !dialogsVisible && + !isFeedbackDialogOpen && + streamingState !== StreamingState.WaitingForConfirmation; + const [controlsHeight, setControlsHeight] = useState(0); + + useLayoutEffect(() => { + if (!mainControlsRef.current) { + setControlsHeight(0); + return; + } + + const fullFooterMeasurement = measureElement(mainControlsRef.current); + setControlsHeight(fullFooterMeasurement.height); + }, [ + buffer, + terminalWidth, + terminalHeight, + btwItem, + dialogsVisible, + shouldShowStickyTodos, + stickyTodos, + ]); + + // agentViewState is declared earlier (before handleFinalSubmit) so it + // is available for input routing. Referenced here for layout computation. + const tabBarHeight = agentViewState.agents.size > 0 ? 1 : 0; + const availableTerminalHeight = Math.max( + 0, + terminalHeight - controlsHeight - staticExtraHeight - 2 - tabBarHeight, + ); + + config.setShellExecutionConfig({ + terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), + terminalHeight: Math.max( + Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), + 1, + ), + pager: settings.merged.tools?.shell?.pager, + showColor: settings.merged.tools?.shell?.showColor, + }); const isInitialMount = useRef(true); + useEffect(() => { + if (activePtyId) { + ShellExecutionService.resizePty( + activePtyId, + Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), + Math.max(Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), 1), + ); + } + }, [terminalWidth, availableTerminalHeight, activePtyId]); + useEffect(() => { if (ideNeedsRestart) { // IDE trust changed, force a restart. @@ -1570,6 +1661,98 @@ export const AppContainer = (props: AppContainerProps) => { setShowEscapePrompt(showPrompt); }, []); + // --- Rewind selector callbacks --- + const openRewindSelector = useCallback(() => { + if (streamingState !== StreamingState.Idle) return; + if (config.getIdeMode()) return; + if (dialogsVisibleRef.current) return; + const hasUserTurns = historyManager.history.some((h) => h.type === 'user'); + if (!hasUserTurns) return; + setIsRewindSelectorOpen(true); + }, [streamingState, config, historyManager.history]); + openRewindSelectorRef.current = openRewindSelector; + + const closeRewindSelector = useCallback(() => { + setIsRewindSelectorOpen(false); + }, []); + + const handleRewindConfirm = useCallback( + (userItem: HistoryItem) => { + const geminiClient = config.getGeminiClient(); + if (!geminiClient) return; + + // 1. Compute values from current history BEFORE truncation + const originalHistory = historyManager.history; + const originalLength = originalHistory.length; + + let targetTurnIndex = 0; + for (const h of originalHistory) { + if (h.id === userItem.id) break; + if (isRealUserTurn(h)) targetTurnIndex++; + } + + // 2. Compute API truncation point + const apiHistory = geminiClient.getHistory(); + const apiTruncateIndex = computeApiTruncationIndex( + originalHistory, + userItem.id, + apiHistory, + ); + + // Abort if the target turn is unreachable (e.g., absorbed by compression) + if (apiTruncateIndex < 0) { + historyManager.addItem( + { + type: 'error', + text: 'Cannot rewind to a turn that was compressed. Try a more recent turn.', + }, + Date.now(), + ); + setIsRewindSelectorOpen(false); + return; + } + + // 3. Truncate API history and strip stale thinking blocks + geminiClient.truncateHistory(apiTruncateIndex); + geminiClient.stripThoughtsFromHistory(); + + // 4. Truncate UI history (keep everything before the target item) + const truncatedUi = originalHistory.filter((h) => h.id < userItem.id); + historyManager.loadHistory(truncatedUi); + + // 5. Re-render the terminal + refreshStatic(); + + // 6. Pre-populate input with the original user text + if (userItem.type === 'user' && userItem.text) { + buffer.setText(userItem.text); + } + + // 7. Add info message + historyManager.addItem( + { + type: 'info', + text: 'Conversation rewound. Edit your prompt and press Enter to continue.', + }, + Date.now(), + ); + + // 8. Record the rewind event — re-roots the parentUuid chain so + // rewound messages end up on a dead branch during resume. + config.getChatRecordingService()?.rewindRecording(targetTurnIndex, { + truncatedCount: originalLength - truncatedUi.length, + }); + + // 9. Close the selector + setIsRewindSelectorOpen(false); + }, + [config, historyManager, refreshStatic, buffer], + ); + + const handleDoubleEscRewind = useDoublePress(openRewindSelector, (pending) => + setRewindEscPending(pending), + ); + const handleIdePromptComplete = useCallback( (result: IdeIntegrationNudgeResult) => { if (result.userSelection === 'yes') { @@ -1865,6 +2048,20 @@ export const AppContainer = (props: AppContainerProps) => { return; } + // Input is empty and idle — double-ESC opens rewind selector + if ( + streamingState === StreamingState.Idle && + !dialogsVisibleRef.current + ) { + if (escapeTimerRef.current) { + clearTimeout(escapeTimerRef.current); + escapeTimerRef.current = null; + } + setEscapePressedOnce(false); + handleDoubleEscRewind(); + return; + } + // No action available, reset the flag if (escapeTimerRef.current) { clearTimeout(escapeTimerRef.current); @@ -1961,6 +2158,7 @@ export const AppContainer = (props: AppContainerProps) => { compactMode, setCompactMode, refreshStatic, + handleDoubleEscRewind, ], ); @@ -2002,55 +2200,40 @@ export const AppContainer = (props: AppContainerProps) => { stdout, ]); - const nightly = props.version.includes('nightly'); + // Drain queued messages when idle. `queueDrainNonce` re-fires the effect + // after each submission settles so multi-step queues drain end-to-end. + const queueDrainingRef = useRef(false); + const [queueDrainNonce, setQueueDrainNonce] = useState(0); + useEffect(() => { + if (queueDrainingRef.current) return; + if (!isConfigInitialized) return; + if (streamingState !== StreamingState.Idle) return; + if (dialogsVisible) return; + if (messageQueue.length === 0) return; - const dialogsVisible = - showWelcomeBackDialog || - shouldShowIdePrompt || - shouldShowCommandMigrationNudge || - isFolderTrustDialogOpen || - !!shellConfirmationRequest || - !!confirmationRequest || - confirmUpdateExtensionRequests.length > 0 || - !!codingPlanUpdateRequest || - settingInputRequests.length > 0 || - pluginChoiceRequests.length > 0 || - !!loopDetectionConfirmationRequest || - isThemeDialogOpen || - isSettingsDialogOpen || - isMemoryDialogOpen || - isModelDialogOpen || - isTrustDialogOpen || - activeArenaDialog !== null || - isPermissionsDialogOpen || - isAuthDialogOpen || - isAuthenticating || - isEditorDialogOpen || - showIdeRestartPrompt || - isSubagentCreateDialogOpen || - isAgentsManagerDialogOpen || - isMcpDialogOpen || - isHooksDialogOpen || - isApprovalModeDialogOpen || - isResumeDialogOpen || - isDeleteDialogOpen || - isExtensionsManagerDialogOpen || - bgTasksDialogOpen; - dialogsVisibleRef.current = dialogsVisible; + // Two-phase: batch plain prompts as one turn, else pop next slash command. + const plainPrompts = drainQueue(); + const submission = + plainPrompts.length > 0 ? plainPrompts.join('\n\n') : popNextSegment(); + if (submission === null) return; - const { - isFeedbackDialogOpen, - openFeedbackDialog, - closeFeedbackDialog, - temporaryCloseFeedbackDialog, - submitFeedback, - } = useFeedbackDialog({ - config, - settings, + queueDrainingRef.current = true; + Promise.resolve(submitQuery(submission)).finally(() => { + queueDrainingRef.current = false; + setQueueDrainNonce((n) => n + 1); + }); + }, [ + isConfigInitialized, streamingState, - history: historyManager.history, - sessionStats, - }); + dialogsVisible, + messageQueue, + drainQueue, + popNextSegment, + submitQuery, + queueDrainNonce, + ]); + + const nightly = props.version.includes('nightly'); const uiState: UIState = useMemo( () => ({ @@ -2126,6 +2309,7 @@ export const AppContainer = (props: AppContainerProps) => { staticExtraHeight, dialogsVisible, pendingHistoryItems, + stickyTodos, btwItem, setBtwItem, cancelBtw, @@ -2169,6 +2353,9 @@ export const AppContainer = (props: AppContainerProps) => { // Prompt suggestion promptSuggestion, dismissPromptSuggestion, + // Rewind selector + isRewindSelectorOpen, + rewindEscPending, }), [ isThemeDialogOpen, @@ -2240,6 +2427,7 @@ export const AppContainer = (props: AppContainerProps) => { staticExtraHeight, dialogsVisible, pendingHistoryItems, + stickyTodos, btwItem, setBtwItem, cancelBtw, @@ -2285,6 +2473,9 @@ export const AppContainer = (props: AppContainerProps) => { // Prompt suggestion promptSuggestion, dismissPromptSuggestion, + // Rewind selector + isRewindSelectorOpen, + rewindEscPending, ], ); @@ -2354,6 +2545,10 @@ export const AppContainer = (props: AppContainerProps) => { closeFeedbackDialog, temporaryCloseFeedbackDialog, submitFeedback, + // Rewind selector + openRewindSelector, + closeRewindSelector, + handleRewindConfirm, }), [ openThemeDialog, @@ -2418,6 +2613,10 @@ export const AppContainer = (props: AppContainerProps) => { closeFeedbackDialog, temporaryCloseFeedbackDialog, submitFeedback, + // Rewind selector + openRewindSelector, + closeRewindSelector, + handleRewindConfirm, ], ); diff --git a/packages/cli/src/ui/commands/renameCommand.test.ts b/packages/cli/src/ui/commands/renameCommand.test.ts index bc334c8b3..854b50d52 100644 --- a/packages/cli/src/ui/commands/renameCommand.test.ts +++ b/packages/cli/src/ui/commands/renameCommand.test.ts @@ -9,16 +9,31 @@ import { renameCommand } from './renameCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +const tryGenerateSessionTitleMock = vi.fn(); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const original = + (await importOriginal()) as typeof import('@qwen-code/qwen-code-core'); + return { + ...original, + tryGenerateSessionTitle: (...args: unknown[]) => + tryGenerateSessionTitleMock(...args), + }; +}); + describe('renameCommand', () => { let mockContext: CommandContext; beforeEach(() => { mockContext = createMockCommandContext(); + tryGenerateSessionTitleMock.mockReset(); }); it('should have the correct name and description', () => { expect(renameCommand.name).toBe('rename'); - expect(renameCommand.description).toBe('Rename the current conversation'); + expect(renameCommand.description).toBe( + 'Rename the current conversation. --auto lets the fast model pick a title.', + ); }); it('should return error when config is not available', async () => { @@ -103,7 +118,7 @@ describe('renameCommand', () => { const result = await renameCommand.action!(mockContext, 'my-feature'); - expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature'); + expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature', 'manual'); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -130,6 +145,7 @@ describe('renameCommand', () => { expect(mockRenameSession).toHaveBeenCalledWith( 'test-session-id', 'my-feature', + 'manual', ); expect(result).toEqual({ type: 'message', @@ -159,4 +175,270 @@ describe('renameCommand', () => { content: 'Failed to rename session.', }); }); + + describe('bare /rename model selection', () => { + // Pins the kebab-case path's model choice: bare `/rename` (no args) + // prefers fastModel when one is configured, falls back to the main + // model otherwise. Previous tests mocked `getHistory: []` which bailed + // before the model selection ran, leaving this regression-prone. + function mockConfigForKebab(opts: { fastModel?: string; model?: string }): { + config: unknown; + generateContent: ReturnType; + } { + const generateContent = vi.fn().mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'fix-login-bug' }] } }], + }); + const config = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: vi.fn().mockReturnValue(true), + }), + getFastModel: vi.fn().mockReturnValue(opts.fastModel), + getModel: vi.fn().mockReturnValue(opts.model ?? 'main-model'), + getGeminiClient: vi.fn().mockReturnValue({ + getHistory: vi.fn().mockReturnValue([ + { role: 'user', parts: [{ text: 'fix the login bug' }] }, + { + role: 'model', + parts: [{ text: 'Looking at the handler now.' }], + }, + ]), + }), + getContentGenerator: vi.fn().mockReturnValue({ generateContent }), + }; + return { config, generateContent }; + } + + it('uses fastModel when configured', async () => { + const { config, generateContent } = mockConfigForKebab({ + fastModel: 'qwen-turbo', + model: 'main-model', + }); + mockContext = createMockCommandContext({ + services: { config: config as never }, + }); + + await renameCommand.action!(mockContext, ''); + + expect(generateContent).toHaveBeenCalledOnce(); + expect(generateContent.mock.calls[0][0].model).toBe('qwen-turbo'); + }); + + it('falls back to main model when fastModel is unset', async () => { + const { config, generateContent } = mockConfigForKebab({ + fastModel: undefined, + model: 'main-model', + }); + mockContext = createMockCommandContext({ + services: { config: config as never }, + }); + + await renameCommand.action!(mockContext, ''); + + expect(generateContent).toHaveBeenCalledOnce(); + expect(generateContent.mock.calls[0][0].model).toBe('main-model'); + }); + }); + + describe('--auto flag', () => { + it('refuses --auto when no fast model is configured', async () => { + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: vi.fn(), + }), + getFastModel: vi.fn().mockReturnValue(undefined), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, '--auto'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + '/rename --auto requires a fast model. Configure one with `/model --fast `.', + }); + expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled(); + }); + + it('refuses --auto combined with a positional name', async () => { + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: vi.fn(), + }), + getFastModel: vi.fn().mockReturnValue('qwen-turbo'), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, '--auto my-name'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + '/rename --auto does not take a name. Use `/rename ` to set a name yourself.', + }); + expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled(); + }); + + it('writes an auto-sourced title on --auto success', async () => { + tryGenerateSessionTitleMock.mockResolvedValue({ + ok: true, + title: 'Fix login button on mobile', + modelUsed: 'qwen-turbo', + }); + const mockRecordCustomTitle = vi.fn().mockReturnValue(true); + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: mockRecordCustomTitle, + }), + getFastModel: vi.fn().mockReturnValue('qwen-turbo'), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, '--auto'); + + expect(tryGenerateSessionTitleMock).toHaveBeenCalledOnce(); + expect(mockRecordCustomTitle).toHaveBeenCalledWith( + 'Fix login button on mobile', + 'auto', + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Session renamed to "Fix login button on mobile"', + }); + }); + + it('surfaces empty_history reason with actionable hint', async () => { + tryGenerateSessionTitleMock.mockResolvedValue({ + ok: false, + reason: 'empty_history', + }); + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: vi.fn(), + }), + getFastModel: vi.fn().mockReturnValue('qwen-turbo'), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, '--auto'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + 'No conversation to title yet — send at least one message first.', + }); + }); + + it('surfaces model_error reason distinctly', async () => { + tryGenerateSessionTitleMock.mockResolvedValue({ + ok: false, + reason: 'model_error', + }); + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: vi.fn(), + }), + getFastModel: vi.fn().mockReturnValue('qwen-turbo'), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, '--auto'); + + expect(result).toMatchObject({ + messageType: 'error', + }); + expect((result as { content: string }).content).toMatch( + /rate limit, auth, or network error/, + ); + }); + + it('rejects unknown flag with sentinel hint', async () => { + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: vi.fn(), + }), + getFastModel: vi.fn().mockReturnValue('qwen-turbo'), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!( + mockContext, + '--my-label-with-dashes', + ); + + expect(result).toMatchObject({ messageType: 'error' }); + const content = (result as { content: string }).content; + expect(content).toMatch(/Unknown flag "--my-label-with-dashes"/); + expect(content).toMatch(/\/rename -- --my-label-with-dashes/); + expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled(); + }); + + it('surfaces aborted reason when user cancels', async () => { + tryGenerateSessionTitleMock.mockResolvedValue({ + ok: false, + reason: 'aborted', + }); + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: vi.fn(), + }), + getFastModel: vi.fn().mockReturnValue('qwen-turbo'), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, '--auto'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Title generation was cancelled.', + }); + }); + + it('falls back to SessionService.renameSession with auto source', async () => { + tryGenerateSessionTitleMock.mockResolvedValue({ + ok: true, + title: 'Audit auth middleware', + modelUsed: 'qwen-turbo', + }); + const mockRenameSession = vi.fn().mockResolvedValue(true); + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getSessionService: vi.fn().mockReturnValue({ + renameSession: mockRenameSession, + }), + getFastModel: vi.fn().mockReturnValue('qwen-turbo'), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, '--auto'); + + expect(mockRenameSession).toHaveBeenCalledWith( + 'test-session-id', + 'Audit auth middleware', + 'auto', + ); + expect(result).toMatchObject({ messageType: 'info' }); + }); + }); }); diff --git a/packages/cli/src/ui/commands/renameCommand.ts b/packages/cli/src/ui/commands/renameCommand.ts index e1a594331..5a4137bf6 100644 --- a/packages/cli/src/ui/commands/renameCommand.ts +++ b/packages/cli/src/ui/commands/renameCommand.ts @@ -5,10 +5,13 @@ */ import type { Content } from '@google/genai'; -import type { Config } from '@qwen-code/qwen-code-core'; import { getResponseText, SESSION_TITLE_MAX_LENGTH, + stripTerminalControlSequences, + tryGenerateSessionTitle, + type Config, + type SessionTitleFailureReason, } from '@qwen-code/qwen-code-core'; import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; @@ -38,9 +41,11 @@ function extractConversationText(history: Content[]): string { } /** - * Calls the LLM to generate a short session title from conversation history. + * Calls the LLM to generate a short kebab-case session title from conversation + * history. Used when `/rename` is invoked with no arguments — produces a + * filesystem-style name for sessions the user wants to keep long-term. */ -async function generateSessionTitle( +async function generateKebabTitle( config: Config, signal?: AbortSignal, ): Promise { @@ -51,9 +56,15 @@ async function generateSessionTitle( return null; } + // Prefer the fast model for title generation — it's much cheaper and + // faster than the main model, and title generation is a small bounded + // task that doesn't need main-model reasoning. Falls back to the main + // model when no fast model is configured so this path never fails to + // start. + const model = config.getFastModel() ?? config.getModel(); const response = await config.getContentGenerator().generateContent( { - model: config.getModel(), + model, contents: [ { role: 'user', @@ -79,8 +90,13 @@ async function generateSessionTitle( if (!text) { return null; } - // Clean up: take first line, remove quotes/backticks - const cleaned = text.split('\n')[0].replace(/["`']/g, '').trim(); + // Clean up: strip ANSI / control sequences via the shared helper + // (same security concern as the sentence-case path — the title renders + // directly in the picker), then take the first line and drop quotes. + const cleaned = stripTerminalControlSequences(text) + .split('\n')[0] + .replace(/["`']/g, '') + .trim(); return cleaned.length > 0 && cleaned.length <= MAX_TITLE_LENGTH ? cleaned : null; @@ -89,12 +105,88 @@ async function generateSessionTitle( } } +/** + * Translate a title-generation failure reason into a human-actionable + * message. Exists so `/rename --auto` doesn't collapse to a generic "could + * not generate" that leaves the user guessing about the cause. + */ +function autoFailureMessage(reason: SessionTitleFailureReason): string { + switch (reason) { + case 'no_fast_model': + return t( + '/rename --auto requires a fast model. Configure one with `/model --fast `.', + ); + case 'empty_history': + return t( + 'No conversation to title yet — send at least one message first.', + ); + case 'empty_result': + return t( + 'The fast model returned no usable title. Try `/rename ` to set one yourself.', + ); + case 'aborted': + return t('Title generation was cancelled.'); + case 'model_error': + return t( + 'The fast model could not generate a title (rate limit, auth, or network error). Check debug log or try again.', + ); + case 'no_client': + return t('Session is still initializing — try again in a moment.'); + default: + return t('Could not generate a title.'); + } +} + +/** + * Parse `--auto` out of the args. Kept simple rather than bringing in an + * argv parser — we only have one flag. + * + * Rules: + * - `--auto` (case-insensitive) sets auto=true. + * - `--` terminates flag parsing; everything after is positional, so users + * can legitimately name sessions starting with `--` via `/rename -- --foo`. + * - Any other `--xxx` before `--` bubbles up as `unknownFlag` for a clean + * error, rather than silently becoming part of the title (`--Auto` typo, + * `--help` expectation, etc.). + */ +function parseArgs(raw: string): { + auto: boolean; + positional: string; + unknownFlag?: string; +} { + const trimmed = raw.trim().replace(/[\r\n]+/g, ' '); + if (!trimmed) return { auto: false, positional: '' }; + const parts = trimmed.split(/\s+/); + let auto = false; + let unknownFlag: string | undefined; + let flagsDone = false; + const rest: string[] = []; + for (const p of parts) { + if (!flagsDone && p === '--') { + flagsDone = true; + continue; + } + if (!flagsDone && p.startsWith('--')) { + if (p.toLowerCase() === '--auto') { + auto = true; + continue; + } + if (!unknownFlag) unknownFlag = p; + continue; + } + rest.push(p); + } + return { auto, positional: rest.join(' '), unknownFlag }; +} + export const renameCommand: SlashCommand = { name: 'rename', altNames: ['tag'], kind: CommandKind.BUILT_IN, get description() { - return t('Rename the current conversation'); + return t( + 'Rename the current conversation. --auto lets the fast model pick a title.', + ); }, action: async (context, args): Promise => { const { config } = context.services; @@ -107,10 +199,87 @@ export const renameCommand: SlashCommand = { }; } - let name = args.trim().replace(/[\r\n]+/g, ' '); + const { auto, positional, unknownFlag } = parseArgs(args); + if (unknownFlag) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Unknown flag "{{flag}}". Supported: --auto. To use this as a literal name, run `/rename -- {{flag}}`.', + { flag: unknownFlag }, + ), + }; + } + let name = positional; + // Track where the title came from so the session picker can dim + // auto-generated titles; explicit user text stays 'manual'. + let titleSource: 'auto' | 'manual' = 'manual'; - // If no name provided, auto-generate one from conversation history - if (!name) { + if (auto) { + // Explicit user-triggered auto-title. This overwrites whatever title + // is currently set (manual or auto) because the user asked for it. + // Requires a configured fast model — we don't silently fall back to + // the main model here because `--auto` is a deliberate opt-in to the + // sentence-case fast-model flow, and surprising a user with a main- + // model call would defeat the purpose. + if (!config.getFastModel()) { + return { + type: 'message', + messageType: 'error', + content: t( + '/rename --auto requires a fast model. Configure one with `/model --fast `.', + ), + }; + } + if (positional) { + return { + type: 'message', + messageType: 'error', + content: t( + '/rename --auto does not take a name. Use `/rename ` to set a name yourself.', + ), + }; + } + const dots = ['.', '..', '...']; + let dotIndex = 0; + const baseText = t('Regenerating session title'); + context.ui.setPendingItem({ + type: 'info', + text: baseText + dots[dotIndex], + }); + const timer = setInterval(() => { + dotIndex = (dotIndex + 1) % dots.length; + context.ui.setPendingItem({ + type: 'info', + text: baseText + dots[dotIndex], + }); + }, 500); + // try/finally ensures the spinner stops even if tryGenerateSessionTitle + // ever throws (it currently swallows internally, but defensively so + // future regressions don't leak an interval timer). + let outcome: Awaited>; + try { + outcome = await tryGenerateSessionTitle( + config, + context.abortSignal ?? new AbortController().signal, + ); + } finally { + clearInterval(timer); + context.ui.setPendingItem(null); + } + if (!outcome.ok) { + return { + type: 'message', + messageType: 'error', + content: autoFailureMessage(outcome.reason), + }; + } + name = outcome.title; + titleSource = 'auto'; + } else if (!name) { + // Legacy no-arg behavior: kebab-case, generated via the main content + // generator with fallback to fastModel. Preserved as-is for users who + // prefer filesystem-style names. const dots = ['.', '..', '...']; let dotIndex = 0; const baseText = t('Generating session name'); @@ -125,9 +294,13 @@ export const renameCommand: SlashCommand = { text: baseText + dots[dotIndex], }); }, 500); - const generated = await generateSessionTitle(config, context.abortSignal); - clearInterval(timer); - context.ui.setPendingItem(null); + let generated: string | null; + try { + generated = await generateKebabTitle(config, context.abortSignal); + } finally { + clearInterval(timer); + context.ui.setPendingItem(null); + } if (!generated) { return { type: 'message', @@ -151,7 +324,7 @@ export const renameCommand: SlashCommand = { // Record the custom title in the current session's JSONL file const chatRecordingService = config.getChatRecordingService(); if (chatRecordingService) { - const ok = chatRecordingService.recordCustomTitle(name); + const ok = chatRecordingService.recordCustomTitle(name, titleSource); if (!ok) { return { type: 'message', @@ -163,7 +336,11 @@ export const renameCommand: SlashCommand = { // Fallback: write via SessionService for non-recording sessions const sessionId = config.getSessionId(); const sessionService = config.getSessionService(); - const success = await sessionService.renameSession(sessionId, name); + const success = await sessionService.renameSession( + sessionId, + name, + titleSource, + ); if (!success) { return { type: 'message', diff --git a/packages/cli/src/ui/commands/rewindCommand.ts b/packages/cli/src/ui/commands/rewindCommand.ts new file mode 100644 index 000000000..020091795 --- /dev/null +++ b/packages/cli/src/ui/commands/rewindCommand.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand, SlashCommandActionReturn } from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; + +export const rewindCommand: SlashCommand = { + name: 'rewind', + altNames: ['rollback'], + get description() { + return t('Rewind conversation to a previous turn'); + }, + kind: CommandKind.BUILT_IN, + action: async (): Promise => ({ + type: 'dialog', + dialog: 'rewind', + }), +}; diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 801da28c9..23687d828 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -24,7 +24,7 @@ export const skillsCommand: SlashCommand = { return t('List available skills.'); }, kind: CommandKind.BUILT_IN, - supportedModes: ['interactive'] as const, + supportedModes: ['interactive', 'acp'] as const, action: async (context: CommandContext, args?: string) => { const rawArgs = args?.trim() ?? ''; const [skillName = ''] = rawArgs.split(/\s+/); diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 01e6d6564..2e3f8df62 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -182,7 +182,8 @@ export interface OpenDialogActionReturn { | 'delete' | 'extensions_manage' | 'hooks' - | 'mcp'; + | 'mcp' + | 'rewind'; } /** diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 263988686..c5bda6bea 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -16,7 +16,6 @@ import { useUIActions } from '../contexts/UIActionsContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { StreamingState, type HistoryItemToolGroup } from '../types.js'; -import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; import { FeedbackDialog } from '../FeedbackDialog.js'; import { t } from '../../i18n/index.js'; @@ -104,8 +103,6 @@ export const Composer = () => { /> )} - {!uiState.isConfigInitialized && } - {uiState.isFeedbackDialogOpen && } diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 919c53a4b..d3aabf40e 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -43,6 +43,7 @@ import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js import { MCPManagementDialog } from './mcp/MCPManagementDialog.js'; import { HooksManagementDialog } from './hooks/HooksManagementDialog.js'; import { SessionPicker } from './SessionPicker.js'; +import { RewindSelector } from './RewindSelector.js'; import { MemoryDialog } from './MemoryDialog.js'; import { BackgroundTasksDialog } from './background-view/BackgroundTasksDialog.js'; import { useBackgroundAgentViewState } from '../contexts/BackgroundAgentViewContext.js'; @@ -384,6 +385,7 @@ export const DialogManager = ({ onSelect={uiActions.handleResume} onCancel={uiActions.closeResumeDialog} initialSessions={uiState.resumeMatchedSessions} + enablePreview /> ); } @@ -400,6 +402,16 @@ export const DialogManager = ({ ); } + if (uiState.isRewindSelectorOpen) { + return ( + + ); + } + // Background tasks dialog — lowest priority so other dialogs // (permissions, trust prompts, auth, etc.) always take precedence. The // dialog is part of the shared dialogsVisible machinery (see diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index fe8ddadab..064206a92 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -79,6 +79,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => contextFileNames: [], showToolDescriptions: false, ideContextState: undefined, + isConfigInitialized: true, ...overrides, }) as UIState; @@ -152,6 +153,43 @@ describe('