Compare commits

..

50 Commits

Author SHA1 Message Date
LaZzyMan
15912892f2 fix: missing error throw in non-Interactive mode 2025-12-30 19:40:24 +08:00
Mingholy
105ad743fa Merge pull request #1284 from tt-a1i/fix/boolean-string-coercion
fix(core): coerce string boolean values in schema validation
2025-12-29 18:27:36 +08:00
mingholy.lmh
ac3f7cb8c8 fix: ts erros in test file 2025-12-29 17:20:25 +08:00
顾盼
e27e9a5f18 Merge pull request #1288 from Weaxs/main
support merge ChatCompletionContentPart && add filterEmptyMessages
2025-12-29 10:50:30 +08:00
pomelo
2578d8c151 Merge pull request #1360 from IceyLiu/icey-feat
docs: add AionUi to ecosystem section
2025-12-29 10:12:53 +08:00
VeryLiu-lab
a877fedc52 docs: add AionUi to ecosystem section
Add AionUi as a graphical interface option for Qwen Code users.
AionUi provides a modern GUI for command-line AI tools including
Qwen Code, offering an alternative to the terminal interface.
2025-12-28 21:56:59 +08:00
pomelo
2bc8079519 Merge pull request #1332 from QwenLM/fix-language
Fix multi-language and documentation related issues.
2025-12-26 23:02:37 +08:00
pomelo-nwu
25dbe98e6e fix(cli): prevent HTML comment escape by sanitizing --!> and --> 2025-12-26 22:45:35 +08:00
pomelo-nwu
e5dbd69899 feat: fix ci 2025-12-26 22:38:44 +08:00
Mingholy
17eb20c134 Merge pull request #1322 from QwenLM/mingholy/feat/headless-slash-commands
feat: support /compress and /summary commands for non-interactive & ACP
2025-12-26 18:10:55 +08:00
mingholy.lmh
5d59ceb6f3 fix: explicit output if command is not supported 2025-12-26 17:55:03 +08:00
mingholy.lmh
7f645b9726 fix: wrong slash_command in systemMessage 2025-12-26 17:55:03 +08:00
mingholy.lmh
8c109be48c refactor: unified allow list of supported commands in ACP or non-interactive mode 2025-12-26 17:55:03 +08:00
mingholy.lmh
e9a1d9a927 fix: failed unit test cases 2025-12-26 17:55:02 +08:00
mingholy.lmh
8aceddffa2 feat: support /compress and /summary commands for non-interactive & ACP
integration
2025-12-26 17:55:02 +08:00
tanzhenxin
cebe0448d0 Merge pull request #1327 from QwenLM/feat/vscode-ide-companion-context-left
context left on vscode ide companion
2025-12-26 17:26:35 +08:00
tanzhenxin
919560e3a4 Merge pull request #1345 from QwenLM/feat/vscode-ida-companion-bash-toolcall-click-2
feat(vscode-ide-companion): in/output part in the bash toolcall can be clicked to open a temporary file
2025-12-26 16:55:35 +08:00
Mingholy
26bd4f882d Merge pull request #1334 from QwenLM/mingholy/chore/skip-bumping-unstable-sdk-version
chore: improve release-sdk workflow
2025-12-26 16:21:53 +08:00
tanzhenxin
3787e95572 Merge pull request #1349 from QwenLM/fix/integration-test-3
fix one flaky integration test
2025-12-26 09:43:02 +08:00
tanzhenxin
7233d37bd1 fix one flaky integration test 2025-12-26 09:20:24 +08:00
yiliang114
93dcca5147 fix(vscode-ide-companion): fix test 2025-12-26 00:28:45 +08:00
cwtuan
f7d04323f3 Enhance VS Code extension description with download link (#1341)
Updated the VS Code extension note with a download link for the Qwen Code Companion.
2025-12-25 23:58:52 +08:00
yiliang114
9a27857f10 feat(vscode-ide-companion): support context left 2025-12-25 23:53:55 +08:00
yiliang114
452f4f3c0e Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-context-left 2025-12-25 23:51:57 +08:00
yiliang114
5cc01e5e09 feat(vscode-ide-companion): support context left 2025-12-25 23:51:50 +08:00
yiliang114
ac0be9fb84 feat(vscode-ide-companion): in/output part in the bash toolcall can be clicked to open a temporary file. 2025-12-25 16:59:32 +08:00
tanzhenxin
257c6705e1 Merge pull request #1343 from QwenLM/fix/integration-test-2
fix one flaky integration test
2025-12-25 16:08:54 +08:00
tanzhenxin
27e7438b75 fix one flaky integration test 2025-12-25 16:08:06 +08:00
tanzhenxin
8a3ff8db12 Merge pull request #1340 from QwenLM/feat/anthropic-provider-1
Follow up on pr #1331
2025-12-25 15:44:52 +08:00
tanzhenxin
26f8b67d4f add missing file 2025-12-25 15:24:56 +08:00
tanzhenxin
b64d636280 anthropic provider support follow-up 2025-12-25 15:24:42 +08:00
tanzhenxin
781c57b438 Merge pull request #1331 from QwenLM/feat/support-anthropic-provider
feat: add Anthropic provider, normalize auth/env config, and centralize logging
2025-12-25 11:44:38 +08:00
mingholy.lmh
c81c24d45d chore: improve release-sdk workflow 2025-12-25 10:46:57 +08:00
mingholy.lmh
4407597794 chore: skip bumping sdk version when release nightly/preview or dry run 2025-12-24 18:12:23 +08:00
pomelo-nwu
101bd5f9b3 i18n: fix missing translations for /clear command 2025-12-24 17:28:49 +08:00
pomelo-nwu
61c626b618 fix: check-i18n script 2025-12-24 17:22:21 +08:00
pomelo-nwu
a28278e950 feat: update code 2025-12-24 17:12:27 +08:00
yiliang114
90bf101040 chore(vscode-ide-companion): simplify the implementation of context remaining 2025-12-24 14:29:25 +08:00
pomelo
a8f7bab544 Merge pull request #1293 from fazilus/feat/russian
feat(i18n): update Russian translation with new strings
2025-12-24 11:38:29 +08:00
pomelo-nwu
4ca62ba836 feat: adjust code 2025-12-24 10:26:30 +08:00
yiliang114
660901e1fd Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-context-left 2025-12-24 10:10:32 +08:00
yiliang114
8e64c5acaf feat(vscode-ide-companion): support context left 2025-12-24 01:09:21 +08:00
pomelo
398a1044ce Merge pull request #1247 from afarber/1244-language-output-default
feat(i18n): auto-detect LLM output language from system locale
2025-12-23 15:49:06 +08:00
Alexander Farber
f07259a7c9 Add German UI language support and normalize locale codes for LLM output 2025-12-20 10:21:16 +01:00
Alexander Farber
4d9f25e9fe Auto-detect LLM output language from system locale on first startup 2025-12-20 10:21:16 +01:00
Fazil
15efeb0107 feat(i18n): update Russian translation with new strings 2025-12-18 15:14:08 +03:00
Weaxs
d2bc46cbb4 remove filterEmptyMessages 2025-12-18 00:55:47 +08:00
Weaxs
84eb5c562f support merge ChatCompletionContentPart && add filterEmptyMessages 2025-12-18 00:46:48 +08:00
Tu Shaokun
7b01b26ff5 perf(core): avoid recompiling schema on retry 2025-12-17 16:27:42 +08:00
Tu Shaokun
0f3e97ea1c fix(core): coerce string boolean values in schema validation
Self-hosted LLMs sometimes return "true"/"false" strings instead of
actual boolean values for tool parameters like `is_background`. This
causes schema validation to fail with type errors.

Fixes #1267
2025-12-17 16:14:02 +08:00
86 changed files with 4273 additions and 1367 deletions

View File

@@ -91,6 +91,8 @@ jobs:
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
cache: 'npm' cache: 'npm'
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
- name: 'Install Dependencies' - name: 'Install Dependencies'
run: |- run: |-
@@ -126,6 +128,14 @@ jobs:
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
MANUAL_VERSION: '${{ inputs.version }}' MANUAL_VERSION: '${{ inputs.version }}'
- name: 'Set SDK package version (local only)'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
# Ensure the package version matches the computed release version.
# This is required for nightly/preview because npm does not allow re-publishing the same version.
npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
- name: 'Build CLI Bundle' - name: 'Build CLI Bundle'
run: | run: |
npm run build npm run build
@@ -158,7 +168,21 @@ jobs:
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
- name: 'Build SDK'
working-directory: 'packages/sdk-typescript'
run: |-
npm run build
- name: 'Publish @qwen-code/sdk'
working-directory: 'packages/sdk-typescript'
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Create and switch to a release branch' - name: 'Create and switch to a release branch'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
id: 'release_branch' id: 'release_branch'
env: env:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
@@ -167,50 +191,22 @@ jobs:
git switch -c "${BRANCH_NAME}" git switch -c "${BRANCH_NAME}"
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
- name: 'Update package version' - name: 'Commit and Push package version (stable only)'
env: if: |-
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}' ${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
run: |-
# Use npm workspaces so the root lockfile is updated consistently.
npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
- name: 'Commit and Conditionally Push package version'
env: env:
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
IS_DRY_RUN: '${{ steps.vars.outputs.is_dry_run }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |- run: |-
# Only persist version bumps after a successful publish.
git add packages/sdk-typescript/package.json package-lock.json git add packages/sdk-typescript/package.json package-lock.json
if git diff --staged --quiet; then if git diff --staged --quiet; then
echo "No version changes to commit" echo "No version changes to commit"
else else
git commit -m "chore(release): sdk-typescript ${RELEASE_TAG}" git commit -m "chore(release): sdk-typescript ${RELEASE_TAG}"
fi fi
if [[ "${IS_DRY_RUN}" == "false" ]]; then echo "Pushing release branch to remote..."
echo "Pushing release branch to remote..." git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
else
echo "Dry run enabled. Skipping push."
fi
- name: 'Build SDK'
working-directory: 'packages/sdk-typescript'
run: |-
npm run build
- name: 'Configure npm for publishing'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version-file: '.nvmrc'
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
- name: 'Publish @qwen-code/sdk'
working-directory: 'packages/sdk-typescript'
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Create GitHub Release and Tag' - name: 'Create GitHub Release and Tag'
if: |- if: |-
@@ -220,16 +216,29 @@ jobs:
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}' PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}'
IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}'
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
REF: '${{ github.event.inputs.ref || github.sha }}'
run: |- run: |-
# For stable releases, use the release branch; for nightly/preview, use the current ref
if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then
TARGET="${REF}"
PRERELEASE_FLAG="--prerelease"
else
TARGET="${RELEASE_BRANCH}"
PRERELEASE_FLAG=""
fi
gh release create "sdk-typescript-${RELEASE_TAG}" \ gh release create "sdk-typescript-${RELEASE_TAG}" \
--target "$RELEASE_BRANCH" \ --target "${TARGET}" \
--title "SDK TypeScript Release ${RELEASE_TAG}" \ --title "SDK TypeScript Release ${RELEASE_TAG}" \
--notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \ --notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \
--generate-notes --generate-notes \
${PRERELEASE_FLAG}
- name: 'Create PR to merge release branch into main' - name: 'Create PR to merge release branch into main'
if: |- if: |-
${{ steps.vars.outputs.is_dry_run == 'false' }} ${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
id: 'pr' id: 'pr'
env: env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
@@ -251,7 +260,7 @@ jobs:
- name: 'Wait for CI checks to complete' - name: 'Wait for CI checks to complete'
if: |- if: |-
${{ steps.vars.outputs.is_dry_run == 'false' }} ${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env: env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}' PR_URL: '${{ steps.pr.outputs.PR_URL }}'
@@ -262,7 +271,7 @@ jobs:
- name: 'Enable auto-merge for release PR' - name: 'Enable auto-merge for release PR'
if: |- if: |-
${{ steps.vars.outputs.is_dry_run == 'false' }} ${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env: env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}' PR_URL: '${{ steps.pr.outputs.PR_URL }}'

View File

@@ -191,6 +191,7 @@ See [settings](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/se
Looking for a graphical interface? Looking for a graphical interface?
- [**AionUi**](https://github.com/iOfficeAI/AionUi) A modern GUI for command-line AI tools including Qwen Code
- [**Gemini CLI Desktop**](https://github.com/Piebald-AI/gemini-cli-desktop) A cross-platform desktop/web/mobile UI for Qwen Code - [**Gemini CLI Desktop**](https://github.com/Piebald-AI/gemini-cli-desktop) A cross-platform desktop/web/mobile UI for Qwen Code
## Troubleshooting ## Troubleshooting

View File

@@ -10,4 +10,5 @@ export default {
mcp: 'MCP', mcp: 'MCP',
'token-caching': 'Token Caching', 'token-caching': 'Token Caching',
sandbox: 'Sandboxing', sandbox: 'Sandboxing',
language: 'i18n',
}; };

View File

@@ -48,7 +48,7 @@ Commands specifically for controlling interface and output language.
| → `ui [language]` | Set UI interface language | `/language ui zh-CN` | | → `ui [language]` | Set UI interface language | `/language ui zh-CN` |
| → `output [language]` | Set LLM output language | `/language output Chinese` | | → `output [language]` | Set LLM output language | `/language output Chinese` |
- Available UI languages: `zh-CN` (Simplified Chinese), `en-US` (English) - Available built-in UI languages: `zh-CN` (Simplified Chinese), `en-US` (English), `ru-RU` (Russian), `de-DE` (German)
- Output language examples: `Chinese`, `English`, `Japanese`, etc. - Output language examples: `Chinese`, `English`, `Japanese`, etc.
### 1.4 Tool and Model Management ### 1.4 Tool and Model Management
@@ -72,17 +72,16 @@ Commands for managing AI tools and models.
Commands for obtaining information and performing system settings. Commands for obtaining information and performing system settings.
| Command | Description | Usage Examples | | Command | Description | Usage Examples |
| --------------- | ----------------------------------------------- | ------------------------------------------------ | | ----------- | ----------------------------------------------- | -------------------------------- |
| `/help` | Display help information for available commands | `/help` or `/?` | | `/help` | Display help information for available commands | `/help` or `/?` |
| `/about` | Display version information | `/about` | | `/about` | Display version information | `/about` |
| `/stats` | Display detailed statistics for current session | `/stats` | | `/stats` | Display detailed statistics for current session | `/stats` |
| `/settings` | Open settings editor | `/settings` | | `/settings` | Open settings editor | `/settings` |
| `/auth` | Change authentication method | `/auth` | | `/auth` | Change authentication method | `/auth` |
| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` | | `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` |
| `/copy` | Copy last output content to clipboard | `/copy` | | `/copy` | Copy last output content to clipboard | `/copy` |
| `/quit-confirm` | Show confirmation dialog before quitting | `/quit-confirm` (shortcut: press `Ctrl+C` twice) | | `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` |
| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` |
### 1.6 Common Shortcuts ### 1.6 Common Shortcuts

View File

@@ -0,0 +1,136 @@
# Internationalization (i18n) & Language
Qwen Code is built for multilingual workflows: it supports UI localization (i18n/l10n) in the CLI, lets you choose the assistant output language, and allows custom UI language packs.
## Overview
From a user point of view, Qwen Codes “internationalization” spans multiple layers:
| Capability / Setting | What it controls | Where stored |
| ------------------------ | ---------------------------------------------------------------------- | ---------------------------- |
| `/language ui` | Terminal UI text (menus, system messages, prompts) | `~/.qwen/settings.json` |
| `/language output` | Language the AI responds in (an output preference, not UI translation) | `~/.qwen/output-language.md` |
| Custom UI language packs | Overrides/extends built-in UI translations | `~/.qwen/locales/*.js` |
## UI Language
This is the CLIs UI localization layer (i18n/l10n): it controls the language of menus, prompts, and system messages.
### Setting the UI Language
Use the `/language ui` command:
```bash
/language ui zh-CN # Chinese
/language ui en-US # English
/language ui ru-RU # Russian
/language ui de-DE # German
```
Aliases are also supported:
```bash
/language ui zh # Chinese
/language ui en # English
/language ui ru # Russian
/language ui de # German
```
### Auto-detection
On first startup, Qwen Code detects your system locale and sets the UI language automatically.
Detection priority:
1. `QWEN_CODE_LANG` environment variable
2. `LANG` environment variable
3. System locale via JavaScript Intl API
4. Default: English
## LLM Output Language
The LLM output language controls what language the AI assistant responds in, regardless of what language you type your questions in.
### How It Works
The LLM output language is controlled by a rule file at `~/.qwen/output-language.md`. This file is automatically included in the LLM's context during startup, instructing it to respond in the specified language.
### Auto-detection
On first startup, if no `output-language.md` file exists, Qwen Code automatically creates one based on your system locale. For example:
- System locale `zh` creates a rule for Chinese responses
- System locale `en` creates a rule for English responses
- System locale `ru` creates a rule for Russian responses
- System locale `de` creates a rule for German responses
### Manual Setting
Use `/language output <language>` to change:
```bash
/language output Chinese
/language output English
/language output Japanese
/language output German
```
Any language name works. The LLM will be instructed to respond in that language.
> [!note]
>
> After changing the output language, restart Qwen Code for the change to take effect.
### File Location
```
~/.qwen/output-language.md
```
## Configuration
### Via Settings Dialog
1. Run `/settings`
2. Find "Language" under General
3. Select your preferred UI language
### Via Environment Variable
```bash
export QWEN_CODE_LANG=zh
```
This influences auto-detection on first startup (if you havent set a UI language and no `output-language.md` file exists yet).
## Custom Language Packs
For UI translations, you can create custom language packs in `~/.qwen/locales/`:
- Example: `~/.qwen/locales/es.js` for Spanish
- Example: `~/.qwen/locales/fr.js` for French
User directory takes precedence over built-in translations.
> [!tip]
>
> Contributions are welcome! If youd like to improve built-in translations or add new languages.
> For a concrete example, see [PR #1238: feat(i18n): add Russian language support](https://github.com/QwenLM/qwen-code/pull/1238).
### Language Pack Format
```javascript
// ~/.qwen/locales/es.js
export default {
Hello: 'Hola',
Settings: 'Configuracion',
// ... more translations
};
```
## Related Commands
- `/language` - Show current language settings
- `/language ui [lang]` - Set UI language
- `/language output <language>` - Set LLM output language
- `/settings` - Open settings dialog

View File

@@ -1,4 +1,6 @@
# Qwen Code overview # Qwen Code overview
[![@qwen-code/qwen-code downloads](https://img.shields.io/npm/dw/@qwen-code/qwen-code.svg)](https://npm-compare.com/@qwen-code/qwen-code)
[![@qwen-code/qwen-code version](https://img.shields.io/npm/v/@qwen-code/qwen-code.svg)](https://www.npmjs.com/package/@qwen-code/qwen-code)
> Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before. > Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before.
@@ -46,7 +48,7 @@ You'll be prompted to log in on first use. That's it! [Continue with Quickstart
> [!note] > [!note]
> >
> **New VS Code Extension (Beta)**: Prefer a graphical interface? Our new **VS Code extension** provides an easy-to-use native IDE experience without requiring terminal familiarity. Simply install from the marketplace and start coding with Qwen Code directly in your sidebar. You can search for **Qwen Code** in the VS Code Marketplace and download it. > **New VS Code Extension (Beta)**: Prefer a graphical interface? Our new **VS Code extension** provides an easy-to-use native IDE experience without requiring terminal familiarity. Simply install from the marketplace and start coding with Qwen Code directly in your sidebar. Download and install the [Qwen Code Companion](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion) now.
## What Qwen Code does for you ## What Qwen Code does for you

View File

@@ -24,6 +24,8 @@ export default tseslint.config(
'.integration-tests/**', '.integration-tests/**',
'packages/**/.integration-test/**', 'packages/**/.integration-test/**',
'dist/**', 'dist/**',
'docs-site/.next/**',
'docs-site/out/**',
], ],
}, },
eslint.configs.recommended, eslint.configs.recommended,

View File

@@ -5,8 +5,6 @@
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { existsSync } from 'node:fs';
import * as path from 'node:path';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
describe('file-system', () => { describe('file-system', () => {
@@ -202,8 +200,8 @@ describe('file-system', () => {
const readAttempt = toolLogs.find( const readAttempt = toolLogs.find(
(log) => log.toolRequest.name === 'read_file', (log) => log.toolRequest.name === 'read_file',
); );
const writeAttempt = toolLogs.find( const editAttempt = toolLogs.find(
(log) => log.toolRequest.name === 'write_file', (log) => log.toolRequest.name === 'edit_file',
); );
const successfulReplace = toolLogs.find( const successfulReplace = toolLogs.find(
(log) => log.toolRequest.name === 'replace' && log.toolRequest.success, (log) => log.toolRequest.name === 'replace' && log.toolRequest.success,
@@ -226,15 +224,15 @@ describe('file-system', () => {
// CRITICAL: Verify that no matter what the model did, it never successfully // CRITICAL: Verify that no matter what the model did, it never successfully
// wrote or replaced anything. // wrote or replaced anything.
if (writeAttempt) { if (editAttempt) {
console.error( console.error(
'A write_file attempt was made when no file should be written.', 'A edit_file attempt was made when no file should be written.',
); );
printDebugInfo(rig, result); printDebugInfo(rig, result);
} }
expect( expect(
writeAttempt, editAttempt,
'write_file should not have been called', 'edit_file should not have been called',
).toBeUndefined(); ).toBeUndefined();
if (successfulReplace) { if (successfulReplace) {
@@ -245,12 +243,5 @@ describe('file-system', () => {
successfulReplace, successfulReplace,
'A successful replace should not have occurred', 'A successful replace should not have occurred',
).toBeUndefined(); ).toBeUndefined();
// Final verification: ensure the file was not created.
const filePath = path.join(rig.testDir!, fileName);
const fileExists = existsSync(filePath);
expect(fileExists, 'The non-existent file should not be created').toBe(
false,
);
}); });
}); });

View File

@@ -952,7 +952,8 @@ describe('Permission Control (E2E)', () => {
TEST_TIMEOUT, TEST_TIMEOUT,
); );
it( // FIXME: This test is flaky and sometimes fails with no tool calls.
it.skip(
'should allow read-only tools without restrictions', 'should allow read-only tools without restrictions',
async () => { async () => {
// Create test files for the model to read // Create test files for the model to read

View File

@@ -314,4 +314,88 @@ describe('System Control (E2E)', () => {
); );
}); });
}); });
describe('supportedCommands API', () => {
it('should return list of supported slash commands', async () => {
const sessionId = crypto.randomUUID();
const generator = (async function* () {
yield {
type: 'user',
session_id: sessionId,
message: { role: 'user', content: 'Hello' },
parent_tool_use_id: null,
} as SDKUserMessage;
})();
const q = query({
prompt: generator,
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
model: 'qwen3-max',
debug: false,
},
});
try {
const result = await q.supportedCommands();
// Start consuming messages to trigger initialization
const messageConsumer = (async () => {
try {
for await (const _message of q) {
// Just consume messages
}
} catch (error) {
// Ignore errors from query being closed
if (error instanceof Error && error.message !== 'Query is closed') {
throw error;
}
}
})();
// Verify result structure
expect(result).toBeDefined();
expect(result).toHaveProperty('commands');
expect(Array.isArray(result?.['commands'])).toBe(true);
const commands = result?.['commands'] as string[];
// Verify default allowed built-in commands are present
expect(commands).toContain('init');
expect(commands).toContain('summary');
expect(commands).toContain('compress');
// Verify commands are sorted
const sortedCommands = [...commands].sort();
expect(commands).toEqual(sortedCommands);
// Verify all commands are strings
commands.forEach((cmd) => {
expect(typeof cmd).toBe('string');
expect(cmd.length).toBeGreaterThan(0);
});
await q.close();
await messageConsumer;
} catch (error) {
await q.close();
throw error;
}
});
it('should throw error when supportedCommands is called on closed query', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
model: 'qwen3-max',
},
});
await q.close();
await expect(q.supportedCommands()).rejects.toThrow('Query is closed');
});
});
}); });

View File

@@ -98,6 +98,14 @@ export class AgentSideConnection implements Client {
); );
} }
/**
* Sends a custom notification to the client.
* Used for extension-specific notifications that are not part of the core ACP protocol.
*/
async sendCustomNotification<T>(method: string, params: T): Promise<void> {
return await this.#connection.sendNotification(method, params);
}
/** /**
* Request permission before running a tool * Request permission before running a tool
* *
@@ -374,6 +382,7 @@ export interface Client {
): Promise<schema.RequestPermissionResponse>; ): Promise<schema.RequestPermissionResponse>;
sessionUpdate(params: schema.SessionNotification): Promise<void>; sessionUpdate(params: schema.SessionNotification): Promise<void>;
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>; authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
sendCustomNotification<T>(method: string, params: T): Promise<void>;
writeTextFile( writeTextFile(
params: schema.WriteTextFileRequest, params: schema.WriteTextFileRequest,
): Promise<schema.WriteTextFileResponse>; ): Promise<schema.WriteTextFileResponse>;

View File

@@ -15,10 +15,10 @@ import {
qwenOAuth2Events, qwenOAuth2Events,
MCPServerConfig, MCPServerConfig,
SessionService, SessionService,
buildApiHistoryFromConversation,
type Config, type Config,
type ConversationRecord, type ConversationRecord,
type DeviceAuthorizationData, type DeviceAuthorizationData,
tokenLimit,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import type { ApprovalModeValue } from './schema.js'; import type { ApprovalModeValue } from './schema.js';
import * as acp from './acp.js'; import * as acp from './acp.js';
@@ -165,9 +165,30 @@ class GeminiAgent {
this.setupFileSystem(config); this.setupFileSystem(config);
const session = await this.createAndStoreSession(config); const session = await this.createAndStoreSession(config);
const configuredModel = (
config.getModel() ||
this.config.getModel() ||
''
).trim();
const modelId = configuredModel || 'default';
const modelName = configuredModel || modelId;
return { return {
sessionId: session.getId(), sessionId: session.getId(),
models: {
currentModelId: modelId,
availableModels: [
{
modelId,
name: modelName,
description: null,
_meta: {
contextLimit: tokenLimit(modelId),
},
},
],
_meta: null,
},
}; };
} }
@@ -327,12 +348,20 @@ class GeminiAgent {
const sessionId = config.getSessionId(); const sessionId = config.getSessionId();
const geminiClient = config.getGeminiClient(); const geminiClient = config.getGeminiClient();
const history = conversation // Use GeminiClient to manage chat lifecycle properly
? buildApiHistoryFromConversation(conversation) // This ensures geminiClient.chat is in sync with the session's chat
: undefined; //
const chat = history // Note: When loading a session, config.initialize() has already been called
? await geminiClient.startChat(history) // in newSessionConfig(), which in turn calls geminiClient.initialize().
: await geminiClient.startChat(); // The GeminiClient.initialize() method checks config.getResumedSessionData()
// and automatically loads the conversation history into the chat instance.
// So we only need to initialize if it hasn't been done yet.
if (!geminiClient.isInitialized()) {
await geminiClient.initialize();
}
// Now get the chat instance that's managed by GeminiClient
const chat = geminiClient.getChat();
const session = new Session( const session = new Session(
sessionId, sessionId,

View File

@@ -93,6 +93,7 @@ export type ModeInfo = z.infer<typeof modeInfoSchema>;
export type ModesData = z.infer<typeof modesDataSchema>; export type ModesData = z.infer<typeof modesDataSchema>;
export type AgentInfo = z.infer<typeof agentInfoSchema>; export type AgentInfo = z.infer<typeof agentInfoSchema>;
export type ModelInfo = z.infer<typeof modelInfoSchema>;
export type PromptCapabilities = z.infer<typeof promptCapabilitiesSchema>; export type PromptCapabilities = z.infer<typeof promptCapabilitiesSchema>;
@@ -254,8 +255,26 @@ export const authenticateUpdateSchema = z.object({
export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>; export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>;
export const acpMetaSchema = z.record(z.unknown()).nullable().optional();
export const modelIdSchema = z.string();
export const modelInfoSchema = z.object({
_meta: acpMetaSchema,
description: z.string().nullable().optional(),
modelId: modelIdSchema,
name: z.string(),
});
export const sessionModelStateSchema = z.object({
_meta: acpMetaSchema,
availableModels: z.array(modelInfoSchema),
currentModelId: modelIdSchema,
});
export const newSessionResponseSchema = z.object({ export const newSessionResponseSchema = z.object({
sessionId: z.string(), sessionId: z.string(),
models: sessionModelStateSchema,
}); });
export const loadSessionResponseSchema = z.null(); export const loadSessionResponseSchema = z.null();
@@ -514,6 +533,13 @@ export const currentModeUpdateSchema = z.object({
export type CurrentModeUpdate = z.infer<typeof currentModeUpdateSchema>; export type CurrentModeUpdate = z.infer<typeof currentModeUpdateSchema>;
export const currentModelUpdateSchema = z.object({
sessionUpdate: z.literal('current_model_update'),
model: modelInfoSchema,
});
export type CurrentModelUpdate = z.infer<typeof currentModelUpdateSchema>;
export const sessionUpdateSchema = z.union([ export const sessionUpdateSchema = z.union([
z.object({ z.object({
content: contentBlockSchema, content: contentBlockSchema,
@@ -555,6 +581,7 @@ export const sessionUpdateSchema = z.union([
sessionUpdate: z.literal('plan'), sessionUpdate: z.literal('plan'),
}), }),
currentModeUpdateSchema, currentModeUpdateSchema,
currentModelUpdateSchema,
availableCommandsUpdateSchema, availableCommandsUpdateSchema,
]); ]);

View File

@@ -41,9 +41,11 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path'; import * as path from 'node:path';
import { z } from 'zod'; import { z } from 'zod';
import { getErrorMessage } from '../../utils/errors.js'; import { getErrorMessage } from '../../utils/errors.js';
import { normalizePartList } from '../../utils/nonInteractiveHelpers.js';
import { import {
handleSlashCommand, handleSlashCommand,
getAvailableCommands, getAvailableCommands,
type NonInteractiveSlashCommandResult,
} from '../../nonInteractiveCliCommands.js'; } from '../../nonInteractiveCliCommands.js';
import type { import type {
AvailableCommand, AvailableCommand,
@@ -63,12 +65,6 @@ import { PlanEmitter } from './emitters/PlanEmitter.js';
import { MessageEmitter } from './emitters/MessageEmitter.js'; import { MessageEmitter } from './emitters/MessageEmitter.js';
import { SubAgentTracker } from './SubAgentTracker.js'; import { SubAgentTracker } from './SubAgentTracker.js';
/**
* Built-in commands that are allowed in ACP integration mode.
* Only safe, read-only commands that don't require interactive UI.
*/
export const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init'];
/** /**
* Session represents an active conversation session with the AI model. * Session represents an active conversation session with the AI model.
* It uses modular components for consistent event emission: * It uses modular components for consistent event emission:
@@ -167,24 +163,26 @@ export class Session implements SessionContext {
const firstTextBlock = params.prompt.find((block) => block.type === 'text'); const firstTextBlock = params.prompt.find((block) => block.type === 'text');
const inputText = firstTextBlock?.text || ''; const inputText = firstTextBlock?.text || '';
let parts: Part[]; let parts: Part[] | null;
if (isSlashCommand(inputText)) { if (isSlashCommand(inputText)) {
// Handle slash command - allow specific built-in commands for ACP integration // Handle slash command - uses default allowed commands (init, summary, compress)
const slashCommandResult = await handleSlashCommand( const slashCommandResult = await handleSlashCommand(
inputText, inputText,
pendingSend, pendingSend,
this.config, this.config,
this.settings, this.settings,
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
); );
if (slashCommandResult) { parts = await this.#processSlashCommandResult(
// Use the result from the slash command slashCommandResult,
parts = slashCommandResult as Part[]; params.prompt,
} else { );
// Slash command didn't return a prompt, continue with normal processing
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); // If parts is null, the command was fully handled (e.g., /summary completed)
// Return early without sending to the model
if (parts === null) {
return { stopReason: 'end_turn' };
} }
} else { } else {
// Normal processing for non-slash commands // Normal processing for non-slash commands
@@ -295,11 +293,10 @@ export class Session implements SessionContext {
async sendAvailableCommandsUpdate(): Promise<void> { async sendAvailableCommandsUpdate(): Promise<void> {
const abortController = new AbortController(); const abortController = new AbortController();
try { try {
// Use default allowed commands from getAvailableCommands
const slashCommands = await getAvailableCommands( const slashCommands = await getAvailableCommands(
this.config, this.config,
this.settings,
abortController.signal, abortController.signal,
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
); );
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol // Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
@@ -647,6 +644,103 @@ export class Session implements SessionContext {
} }
} }
/**
* Processes the result of a slash command execution.
*
* Supported result types in ACP mode:
* - submit_prompt: Submits content to the model
* - stream_messages: Streams multiple messages to the client (ACP-specific)
* - unsupported: Command cannot be executed in ACP mode
* - no_command: No command was found, use original prompt
*
* Note: 'message' type is not supported in ACP mode - commands should use
* 'stream_messages' instead for consistent async handling.
*
* @param result The result from handleSlashCommand
* @param originalPrompt The original prompt blocks
* @returns Parts to use for the prompt, or null if command was handled without needing model interaction
*/
async #processSlashCommandResult(
result: NonInteractiveSlashCommandResult,
originalPrompt: acp.ContentBlock[],
): Promise<Part[] | null> {
switch (result.type) {
case 'submit_prompt':
// Command wants to submit a prompt to the model
// Convert PartListUnion to Part[]
return normalizePartList(result.content);
case 'message': {
// 'message' type is not ideal for ACP mode, but we handle it for compatibility
// by converting it to a stream_messages-like notification
await this.client.sendCustomNotification('_qwencode/slash_command', {
sessionId: this.sessionId,
command: originalPrompt
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join(' '),
messageType: result.messageType,
message: result.content || '',
});
if (result.messageType === 'error') {
// Throw error to stop execution
throw new Error(result.content || 'Slash command failed.');
}
// For info messages, return null to indicate command was handled
return null;
}
case 'stream_messages': {
// Command returns multiple messages via async generator (ACP-preferred)
const command = originalPrompt
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join(' ');
// Stream all messages to the client
for await (const msg of result.messages) {
await this.client.sendCustomNotification('_qwencode/slash_command', {
sessionId: this.sessionId,
command,
messageType: msg.messageType,
message: msg.content,
});
// If we encounter an error message, throw after sending
if (msg.messageType === 'error') {
throw new Error(msg.content || 'Slash command failed.');
}
}
// All messages sent successfully, return null to indicate command was handled
return null;
}
case 'unsupported': {
// Command returned an unsupported result type
const unsupportedError = `Slash command not supported in ACP integration: ${result.reason}`;
throw new Error(unsupportedError);
}
case 'no_command':
// No command was found or executed, use original prompt
return originalPrompt.map((block) => {
if (block.type === 'text') {
return { text: block.text };
}
throw new Error(`Unsupported block type: ${block.type}`);
});
default: {
// Exhaustiveness check
const _exhaustive: never = result;
const unknownError = `Unknown slash command result type: ${(_exhaustive as NonInteractiveSlashCommandResult).type}`;
throw new Error(unknownError);
}
}
}
async #resolvePrompt( async #resolvePrompt(
message: acp.ContentBlock[], message: acp.ContentBlock[],
abortSignal: AbortSignal, abortSignal: AbortSignal,

View File

@@ -15,6 +15,7 @@ import { type LoadedSettings, SettingScope } from '../config/settings.js';
import { performInitialAuth } from './auth.js'; import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js'; import { validateTheme } from './theme.js';
import { initializeI18n } from '../i18n/index.js'; import { initializeI18n } from '../i18n/index.js';
import { initializeLlmOutputLanguage } from '../ui/commands/languageCommand.js';
export interface InitializationResult { export interface InitializationResult {
authError: string | null; authError: string | null;
@@ -41,6 +42,9 @@ export async function initializeApp(
'auto'; 'auto';
await initializeI18n(languageSetting); await initializeI18n(languageSetting);
// Auto-detect and set LLM output language on first use
initializeLlmOutputLanguage();
const authType = settings.merged.security?.auth?.selectedType; const authType = settings.merged.security?.auth?.selectedType;
const authError = await performInitialAuth(config, authType); const authError = await performInitialAuth(config, authType);

View File

@@ -1,6 +1,6 @@
/** /**
* @license * @license
* Copyright 2025 Qwen * Copyright 2025 Qwen team
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
@@ -8,15 +8,21 @@ import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import {
type SupportedLanguage,
getLanguageNameFromLocale,
} from './languages.js';
export type SupportedLanguage = 'en' | 'zh' | 'ru' | string; // Allow custom language codes export type { SupportedLanguage };
export { getLanguageNameFromLocale };
// State // State
let currentLanguage: SupportedLanguage = 'en'; let currentLanguage: SupportedLanguage = 'en';
let translations: Record<string, string> = {}; let translations: Record<string, string | string[]> = {};
// Cache // Cache
type TranslationDict = Record<string, string>; type TranslationValue = string | string[];
type TranslationDict = Record<string, TranslationValue>;
const translationCache: Record<string, TranslationDict> = {}; const translationCache: Record<string, TranslationDict> = {};
const loadingPromises: Record<string, Promise<TranslationDict>> = {}; const loadingPromises: Record<string, Promise<TranslationDict>> = {};
@@ -52,11 +58,13 @@ export function detectSystemLanguage(): SupportedLanguage {
if (envLang?.startsWith('zh')) return 'zh'; if (envLang?.startsWith('zh')) return 'zh';
if (envLang?.startsWith('en')) return 'en'; if (envLang?.startsWith('en')) return 'en';
if (envLang?.startsWith('ru')) return 'ru'; if (envLang?.startsWith('ru')) return 'ru';
if (envLang?.startsWith('de')) return 'de';
try { try {
const locale = Intl.DateTimeFormat().resolvedOptions().locale; const locale = Intl.DateTimeFormat().resolvedOptions().locale;
if (locale.startsWith('zh')) return 'zh'; if (locale.startsWith('zh')) return 'zh';
if (locale.startsWith('ru')) return 'ru'; if (locale.startsWith('ru')) return 'ru';
if (locale.startsWith('de')) return 'de';
} catch { } catch {
// Fallback to default // Fallback to default
} }
@@ -224,9 +232,25 @@ export function getCurrentLanguage(): SupportedLanguage {
export function t(key: string, params?: Record<string, string>): string { export function t(key: string, params?: Record<string, string>): string {
const translation = translations[key] ?? key; const translation = translations[key] ?? key;
if (Array.isArray(translation)) {
return key;
}
return interpolate(translation, params); return interpolate(translation, params);
} }
/**
* Get a translation that is an array of strings.
* @param key The translation key
* @returns The array of strings, or an empty array if not found or not an array
*/
export function ta(key: string): string[] {
const translation = translations[key];
if (Array.isArray(translation)) {
return translation;
}
return [];
}
export async function initializeI18n( export async function initializeI18n(
lang?: SupportedLanguage | 'auto', lang?: SupportedLanguage | 'auto',
): Promise<void> { ): Promise<void> {

View File

@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Qwen team
* SPDX-License-Identifier: Apache-2.0
*/
export type SupportedLanguage = 'en' | 'zh' | 'ru' | 'de' | string;
export interface LanguageDefinition {
/** The internal locale code used by the i18n system (e.g., 'en', 'zh'). */
code: SupportedLanguage;
/** The standard name used in UI settings (e.g., 'en-US', 'zh-CN'). */
id: string;
/** The full English name of the language (e.g., 'English', 'Chinese'). */
fullName: string;
}
export const SUPPORTED_LANGUAGES: readonly LanguageDefinition[] = [
{
code: 'en',
id: 'en-US',
fullName: 'English',
},
{
code: 'zh',
id: 'zh-CN',
fullName: 'Chinese',
},
{
code: 'ru',
id: 'ru-RU',
fullName: 'Russian',
},
{
code: 'de',
id: 'de-DE',
fullName: 'German',
},
];
/**
* Maps a locale code to its English language name.
* Used for LLM output language instructions.
*/
export function getLanguageNameFromLocale(locale: SupportedLanguage): string {
const lang = SUPPORTED_LANGUAGES.find((l) => l.code === locale);
return lang?.fullName || 'English';
}

View File

@@ -102,8 +102,8 @@ export default {
'Theme "{{themeName}}" not found.': 'Theme "{{themeName}}" not found.', 'Theme "{{themeName}}" not found.': 'Theme "{{themeName}}" not found.',
'Theme "{{themeName}}" not found in selected scope.': 'Theme "{{themeName}}" not found in selected scope.':
'Theme "{{themeName}}" not found in selected scope.', 'Theme "{{themeName}}" not found in selected scope.',
'clear the screen and conversation history': 'Clear conversation history and free up context':
'clear the screen and conversation history', 'Clear conversation history and free up context',
'Compresses the context by replacing it with a summary.': 'Compresses the context by replacing it with a summary.':
'Compresses the context by replacing it with a summary.', 'Compresses the context by replacing it with a summary.',
'open full Qwen Code documentation in your browser': 'open full Qwen Code documentation in your browser':
@@ -258,6 +258,8 @@ export default {
', Tab to change focus': ', Tab to change focus', ', Tab to change focus': ', Tab to change focus',
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.', 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
'The command "/{{command}}" is not supported in non-interactive mode.':
'The command "/{{command}}" is not supported in non-interactive mode.',
// ============================================================================ // ============================================================================
// Settings Labels // Settings Labels
// ============================================================================ // ============================================================================
@@ -590,6 +592,12 @@ export default {
'No conversation found to summarize.': 'No conversation found to summarize.', 'No conversation found to summarize.': 'No conversation found to summarize.',
'Failed to generate project context summary: {{error}}': 'Failed to generate project context summary: {{error}}':
'Failed to generate project context summary: {{error}}', 'Failed to generate project context summary: {{error}}',
'Saved project summary to {{filePathForDisplay}}.':
'Saved project summary to {{filePathForDisplay}}.',
'Saving project summary...': 'Saving project summary...',
'Generating project summary...': 'Generating project summary...',
'Failed to generate summary - no text content received from LLM response':
'Failed to generate summary - no text content received from LLM response',
// ============================================================================ // ============================================================================
// Commands - Model // Commands - Model
@@ -604,9 +612,10 @@ export default {
// ============================================================================ // ============================================================================
// Commands - Clear // Commands - Clear
// ============================================================================ // ============================================================================
'Clearing terminal and resetting chat.': 'Starting a new session, resetting chat, and clearing terminal.':
'Clearing terminal and resetting chat.', 'Starting a new session, resetting chat, and clearing terminal.',
'Clearing terminal.': 'Clearing terminal.', 'Starting a new session and clearing.':
'Starting a new session and clearing.',
// ============================================================================ // ============================================================================
// Commands - Compress // Commands - Compress
@@ -927,192 +936,138 @@ export default {
// ============================================================================ // ============================================================================
'Waiting for user confirmation...': 'Waiting for user confirmation...', 'Waiting for user confirmation...': 'Waiting for user confirmation...',
'(esc to cancel, {{time}})': '(esc to cancel, {{time}})', '(esc to cancel, {{time}})': '(esc to cancel, {{time}})',
"I'm Feeling Lucky": "I'm Feeling Lucky",
'Shipping awesomeness... ': 'Shipping awesomeness... ', // ============================================================================
'Painting the serifs back on...': 'Painting the serifs back on...', // Loading Phrases
'Navigating the slime mold...': 'Navigating the slime mold...', // ============================================================================
'Consulting the digital spirits...': 'Consulting the digital spirits...', WITTY_LOADING_PHRASES: [
'Reticulating splines...': 'Reticulating splines...', "I'm Feeling Lucky",
'Warming up the AI hamsters...': 'Warming up the AI hamsters...', 'Shipping awesomeness... ',
'Asking the magic conch shell...': 'Asking the magic conch shell...', 'Painting the serifs back on...',
'Generating witty retort...': 'Generating witty retort...', 'Navigating the slime mold...',
'Polishing the algorithms...': 'Polishing the algorithms...', 'Consulting the digital spirits...',
"Don't rush perfection (or my code)...": 'Reticulating splines...',
'Warming up the AI hamsters...',
'Asking the magic conch shell...',
'Generating witty retort...',
'Polishing the algorithms...',
"Don't rush perfection (or my code)...", "Don't rush perfection (or my code)...",
'Brewing fresh bytes...': 'Brewing fresh bytes...', 'Brewing fresh bytes...',
'Counting electrons...': 'Counting electrons...', 'Counting electrons...',
'Engaging cognitive processors...': 'Engaging cognitive processors...', 'Engaging cognitive processors...',
'Checking for syntax errors in the universe...':
'Checking for syntax errors in the universe...', 'Checking for syntax errors in the universe...',
'One moment, optimizing humor...': 'One moment, optimizing humor...', 'One moment, optimizing humor...',
'Shuffling punchlines...': 'Shuffling punchlines...', 'Shuffling punchlines...',
'Untangling neural nets...': 'Untangling neural nets...', 'Untangling neural nets...',
'Compiling brilliance...': 'Compiling brilliance...', 'Compiling brilliance...',
'Loading wit.exe...': 'Loading wit.exe...', 'Loading wit.exe...',
'Summoning the cloud of wisdom...': 'Summoning the cloud of wisdom...', 'Summoning the cloud of wisdom...',
'Preparing a witty response...': 'Preparing a witty response...', 'Preparing a witty response...',
"Just a sec, I'm debugging reality...":
"Just a sec, I'm debugging reality...", "Just a sec, I'm debugging reality...",
'Confuzzling the options...': 'Confuzzling the options...', 'Confuzzling the options...',
'Tuning the cosmic frequencies...': 'Tuning the cosmic frequencies...', 'Tuning the cosmic frequencies...',
'Crafting a response worthy of your patience...':
'Crafting a response worthy of your patience...', 'Crafting a response worthy of your patience...',
'Compiling the 1s and 0s...': 'Compiling the 1s and 0s...', 'Compiling the 1s and 0s...',
'Resolving dependencies... and existential crises...':
'Resolving dependencies... and existential crises...', 'Resolving dependencies... and existential crises...',
'Defragmenting memories... both RAM and personal...':
'Defragmenting memories... both RAM and personal...', 'Defragmenting memories... both RAM and personal...',
'Rebooting the humor module...': 'Rebooting the humor module...', 'Rebooting the humor module...',
'Caching the essentials (mostly cat memes)...':
'Caching the essentials (mostly cat memes)...', 'Caching the essentials (mostly cat memes)...',
'Optimizing for ludicrous speed': 'Optimizing for ludicrous speed', 'Optimizing for ludicrous speed',
"Swapping bits... don't tell the bytes...":
"Swapping bits... don't tell the bytes...", "Swapping bits... don't tell the bytes...",
'Garbage collecting... be right back...':
'Garbage collecting... be right back...', 'Garbage collecting... be right back...',
'Assembling the interwebs...': 'Assembling the interwebs...', 'Assembling the interwebs...',
'Converting coffee into code...': 'Converting coffee into code...', 'Converting coffee into code...',
'Updating the syntax for reality...': 'Updating the syntax for reality...', 'Updating the syntax for reality...',
'Rewiring the synapses...': 'Rewiring the synapses...', 'Rewiring the synapses...',
'Looking for a misplaced semicolon...':
'Looking for a misplaced semicolon...', 'Looking for a misplaced semicolon...',
"Greasin' the cogs of the machine...": "Greasin' the cogs of the machine...", "Greasin' the cogs of the machine...",
'Pre-heating the servers...': 'Pre-heating the servers...', 'Pre-heating the servers...',
'Calibrating the flux capacitor...': 'Calibrating the flux capacitor...', 'Calibrating the flux capacitor...',
'Engaging the improbability drive...': 'Engaging the improbability drive...', 'Engaging the improbability drive...',
'Channeling the Force...': 'Channeling the Force...', 'Channeling the Force...',
'Aligning the stars for optimal response...':
'Aligning the stars for optimal response...', 'Aligning the stars for optimal response...',
'So say we all...': 'So say we all...', 'So say we all...',
'Loading the next great idea...': 'Loading the next great idea...', 'Loading the next great idea...',
"Just a moment, I'm in the zone...": "Just a moment, I'm in the zone...", "Just a moment, I'm in the zone...",
'Preparing to dazzle you with brilliance...':
'Preparing to dazzle you with brilliance...', 'Preparing to dazzle you with brilliance...',
"Just a tick, I'm polishing my wit...":
"Just a tick, I'm polishing my wit...", "Just a tick, I'm polishing my wit...",
"Hold tight, I'm crafting a masterpiece...":
"Hold tight, I'm crafting a masterpiece...", "Hold tight, I'm crafting a masterpiece...",
"Just a jiffy, I'm debugging the universe...":
"Just a jiffy, I'm debugging the universe...", "Just a jiffy, I'm debugging the universe...",
"Just a moment, I'm aligning the pixels...":
"Just a moment, I'm aligning the pixels...", "Just a moment, I'm aligning the pixels...",
"Just a sec, I'm optimizing the humor...":
"Just a sec, I'm optimizing the humor...", "Just a sec, I'm optimizing the humor...",
"Just a moment, I'm tuning the algorithms...":
"Just a moment, I'm tuning the algorithms...", "Just a moment, I'm tuning the algorithms...",
'Warp speed engaged...': 'Warp speed engaged...', 'Warp speed engaged...',
'Mining for more Dilithium crystals...':
'Mining for more Dilithium crystals...', 'Mining for more Dilithium crystals...',
"Don't panic...": "Don't panic...", "Don't panic...",
'Following the white rabbit...': 'Following the white rabbit...', 'Following the white rabbit...',
'The truth is in here... somewhere...':
'The truth is in here... somewhere...', 'The truth is in here... somewhere...',
'Blowing on the cartridge...': 'Blowing on the cartridge...', 'Blowing on the cartridge...',
'Loading... Do a barrel roll!': 'Loading... Do a barrel roll!', 'Loading... Do a barrel roll!',
'Waiting for the respawn...': 'Waiting for the respawn...', 'Waiting for the respawn...',
'Finishing the Kessel Run in less than 12 parsecs...':
'Finishing the Kessel Run in less than 12 parsecs...', 'Finishing the Kessel Run in less than 12 parsecs...',
"The cake is not a lie, it's just still loading...":
"The cake is not a lie, it's just still loading...", "The cake is not a lie, it's just still loading...",
'Fiddling with the character creation screen...':
'Fiddling with the character creation screen...', 'Fiddling with the character creation screen...',
"Just a moment, I'm finding the right meme...":
"Just a moment, I'm finding the right meme...", "Just a moment, I'm finding the right meme...",
"Pressing 'A' to continue...": "Pressing 'A' to continue...", "Pressing 'A' to continue...",
'Herding digital cats...': 'Herding digital cats...', 'Herding digital cats...',
'Polishing the pixels...': 'Polishing the pixels...', 'Polishing the pixels...',
'Finding a suitable loading screen pun...':
'Finding a suitable loading screen pun...', 'Finding a suitable loading screen pun...',
'Distracting you with this witty phrase...':
'Distracting you with this witty phrase...', 'Distracting you with this witty phrase...',
'Almost there... probably...': 'Almost there... probably...', 'Almost there... probably...',
'Our hamsters are working as fast as they can...':
'Our hamsters are working as fast as they can...', 'Our hamsters are working as fast as they can...',
'Giving Cloudy a pat on the head...': 'Giving Cloudy a pat on the head...', 'Giving Cloudy a pat on the head...',
'Petting the cat...': 'Petting the cat...', 'Petting the cat...',
'Rickrolling my boss...': 'Rickrolling my boss...', 'Rickrolling my boss...',
'Never gonna give you up, never gonna let you down...':
'Never gonna give you up, never gonna let you down...', 'Never gonna give you up, never gonna let you down...',
'Slapping the bass...': 'Slapping the bass...', 'Slapping the bass...',
'Tasting the snozberries...': 'Tasting the snozberries...', 'Tasting the snozberries...',
"I'm going the distance, I'm going for speed...":
"I'm going the distance, I'm going for speed...", "I'm going the distance, I'm going for speed...",
'Is this the real life? Is this just fantasy?...':
'Is this the real life? Is this just fantasy?...', 'Is this the real life? Is this just fantasy?...',
"I've got a good feeling about this...":
"I've got a good feeling about this...", "I've got a good feeling about this...",
'Poking the bear...': 'Poking the bear...', 'Poking the bear...',
'Doing research on the latest memes...':
'Doing research on the latest memes...', 'Doing research on the latest memes...',
'Figuring out how to make this more witty...':
'Figuring out how to make this more witty...', 'Figuring out how to make this more witty...',
'Hmmm... let me think...': 'Hmmm... let me think...', 'Hmmm... let me think...',
'What do you call a fish with no eyes? A fsh...':
'What do you call a fish with no eyes? A fsh...', 'What do you call a fish with no eyes? A fsh...',
'Why did the computer go to therapy? It had too many bytes...':
'Why did the computer go to therapy? It had too many bytes...', 'Why did the computer go to therapy? It had too many bytes...',
"Why don't programmers like nature? It has too many bugs...":
"Why don't programmers like nature? It has too many bugs...", "Why don't programmers like nature? It has too many bugs...",
'Why do programmers prefer dark mode? Because light attracts bugs...':
'Why do programmers prefer dark mode? Because light attracts bugs...', 'Why do programmers prefer dark mode? Because light attracts bugs...',
'Why did the developer go broke? Because they used up all their cache...':
'Why did the developer go broke? Because they used up all their cache...', 'Why did the developer go broke? Because they used up all their cache...',
"What can you do with a broken pencil? Nothing, it's pointless...":
"What can you do with a broken pencil? Nothing, it's pointless...", "What can you do with a broken pencil? Nothing, it's pointless...",
'Applying percussive maintenance...': 'Applying percussive maintenance...', 'Applying percussive maintenance...',
'Searching for the correct USB orientation...':
'Searching for the correct USB orientation...', 'Searching for the correct USB orientation...',
'Ensuring the magic smoke stays inside the wires...':
'Ensuring the magic smoke stays inside the wires...', 'Ensuring the magic smoke stays inside the wires...',
'Rewriting in Rust for no particular reason...':
'Rewriting in Rust for no particular reason...', 'Rewriting in Rust for no particular reason...',
'Trying to exit Vim...': 'Trying to exit Vim...', 'Trying to exit Vim...',
'Spinning up the hamster wheel...': 'Spinning up the hamster wheel...', 'Spinning up the hamster wheel...',
"That's not a bug, it's an undocumented feature...":
"That's not a bug, it's an undocumented feature...", "That's not a bug, it's an undocumented feature...",
'Engage.': 'Engage.', 'Engage.',
"I'll be back... with an answer.": "I'll be back... with an answer.", "I'll be back... with an answer.",
'My other process is a TARDIS...': 'My other process is a TARDIS...', 'My other process is a TARDIS...',
'Communing with the machine spirit...':
'Communing with the machine spirit...', 'Communing with the machine spirit...',
'Letting the thoughts marinate...': 'Letting the thoughts marinate...', 'Letting the thoughts marinate...',
'Just remembered where I put my keys...':
'Just remembered where I put my keys...', 'Just remembered where I put my keys...',
'Pondering the orb...': 'Pondering the orb...', 'Pondering the orb...',
"I've seen things you people wouldn't believe... like a user who reads loading messages.":
"I've seen things you people wouldn't believe... like a user who reads loading messages.", "I've seen things you people wouldn't believe... like a user who reads loading messages.",
'Initiating thoughtful gaze...': 'Initiating thoughtful gaze...', 'Initiating thoughtful gaze...',
"What's a computer's favorite snack? Microchips.":
"What's a computer's favorite snack? Microchips.", "What's a computer's favorite snack? Microchips.",
"Why do Java developers wear glasses? Because they don't C#.":
"Why do Java developers wear glasses? Because they don't C#.", "Why do Java developers wear glasses? Because they don't C#.",
'Charging the laser... pew pew!': 'Charging the laser... pew pew!', 'Charging the laser... pew pew!',
'Dividing by zero... just kidding!': 'Dividing by zero... just kidding!', 'Dividing by zero... just kidding!',
'Looking for an adult superviso... I mean, processing.':
'Looking for an adult superviso... I mean, processing.', 'Looking for an adult superviso... I mean, processing.',
'Making it go beep boop.': 'Making it go beep boop.', 'Making it go beep boop.',
'Buffering... because even AIs need a moment.':
'Buffering... because even AIs need a moment.', 'Buffering... because even AIs need a moment.',
'Entangling quantum particles for a faster response...':
'Entangling quantum particles for a faster response...', 'Entangling quantum particles for a faster response...',
'Polishing the chrome... on the algorithms.':
'Polishing the chrome... on the algorithms.', 'Polishing the chrome... on the algorithms.',
'Are you not entertained? (Working on it!)':
'Are you not entertained? (Working on it!)', 'Are you not entertained? (Working on it!)',
'Summoning the code gremlins... to help, of course.':
'Summoning the code gremlins... to help, of course.', 'Summoning the code gremlins... to help, of course.',
'Just waiting for the dial-up tone to finish...':
'Just waiting for the dial-up tone to finish...', 'Just waiting for the dial-up tone to finish...',
'Recalibrating the humor-o-meter.': 'Recalibrating the humor-o-meter.', 'Recalibrating the humor-o-meter.',
'My other loading screen is even funnier.':
'My other loading screen is even funnier.', 'My other loading screen is even funnier.',
"Pretty sure there's a cat walking on the keyboard somewhere...":
"Pretty sure there's a cat walking on the keyboard somewhere...", "Pretty sure there's a cat walking on the keyboard somewhere...",
'Enhancing... Enhancing... Still loading.':
'Enhancing... Enhancing... Still loading.', 'Enhancing... Enhancing... Still loading.',
"It's not a bug, it's a feature... of this loading screen.":
"It's not a bug, it's a feature... of this loading screen.", "It's not a bug, it's a feature... of this loading screen.",
'Have you tried turning it off and on again? (The loading screen, not me.)':
'Have you tried turning it off and on again? (The loading screen, not me.)', 'Have you tried turning it off and on again? (The loading screen, not me.)',
'Constructing additional pylons...': 'Constructing additional pylons...', 'Constructing additional pylons...',
],
}; };

View File

@@ -103,8 +103,8 @@ export default {
'Theme "{{themeName}}" not found.': 'Тема "{{themeName}}" не найдена.', 'Theme "{{themeName}}" not found.': 'Тема "{{themeName}}" не найдена.',
'Theme "{{themeName}}" not found in selected scope.': 'Theme "{{themeName}}" not found in selected scope.':
'Тема "{{themeName}}" не найдена в выбранной области.', 'Тема "{{themeName}}" не найдена в выбранной области.',
'clear the screen and conversation history': 'Clear conversation history and free up context':
'Очистка экрана и истории диалога', 'Очистить историю диалога и освободить контекст',
'Compresses the context by replacing it with a summary.': 'Compresses the context by replacing it with a summary.':
'Сжатие контекста заменой на краткую сводку', 'Сжатие контекста заменой на краткую сводку',
'open full Qwen Code documentation in your browser': 'open full Qwen Code documentation in your browser':
@@ -260,7 +260,8 @@ export default {
', Tab to change focus': ', Tab для смены фокуса', ', Tab to change focus': ', Tab для смены фокуса',
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.', 'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.',
'The command "/{{command}}" is not supported in non-interactive mode.':
'Команда "/{{command}}" не поддерживается в неинтерактивном режиме.',
// ============================================================================ // ============================================================================
// Метки настроек // Метки настроек
// ============================================================================ // ============================================================================
@@ -313,6 +314,7 @@ export default {
'Tool Output Truncation Lines': 'Лимит строк вывода инструментов', 'Tool Output Truncation Lines': 'Лимит строк вывода инструментов',
'Folder Trust': 'Доверие к папке', 'Folder Trust': 'Доверие к папке',
'Vision Model Preview': 'Визуальная модель (предпросмотр)', 'Vision Model Preview': 'Визуальная модель (предпросмотр)',
'Tool Schema Compliance': 'Соответствие схеме инструмента',
// Варианты перечислений настроек // Варианты перечислений настроек
'Auto (detect from system)': 'Авто (определить из системы)', 'Auto (detect from system)': 'Авто (определить из системы)',
Text: 'Текст', Text: 'Текст',
@@ -341,8 +343,8 @@ export default {
'Установка предпочитаемого внешнего редактора', 'Установка предпочитаемого внешнего редактора',
'Manage extensions': 'Управление расширениями', 'Manage extensions': 'Управление расширениями',
'List active extensions': 'Показать активные расширения', 'List active extensions': 'Показать активные расширения',
'Update extensions. Usage: update |--all': 'Update extensions. Usage: update <extension-names>|--all':
'Обновить расширения. Использование: update |--all', 'Обновить расширения. Использование: update <extension-names>|--all',
'manage IDE integration': 'Управление интеграцией с IDE', 'manage IDE integration': 'Управление интеграцией с IDE',
'check status of IDE integration': 'Проверить статус интеграции с IDE', 'check status of IDE integration': 'Проверить статус интеграции с IDE',
'install required IDE companion for {{ideName}}': 'install required IDE companion for {{ideName}}':
@@ -400,7 +402,8 @@ export default {
'Set LLM output language': 'Установка языка вывода LLM', 'Set LLM output language': 'Установка языка вывода LLM',
'Usage: /language ui [zh-CN|en-US]': 'Usage: /language ui [zh-CN|en-US]':
'Использование: /language ui [zh-CN|en-US|ru-RU]', 'Использование: /language ui [zh-CN|en-US|ru-RU]',
'Usage: /language output ': 'Использование: /language output ', 'Usage: /language output <language>':
'Использование: /language output <language>',
'Example: /language output 中文': 'Пример: /language output 中文', 'Example: /language output 中文': 'Пример: /language output 中文',
'Example: /language output English': 'Пример: /language output English', 'Example: /language output English': 'Пример: /language output English',
'Example: /language output 日本語': 'Пример: /language output 日本語', 'Example: /language output 日本語': 'Пример: /language output 日本語',
@@ -417,9 +420,8 @@ export default {
'To request additional UI language packs, please open an issue on GitHub.': 'To request additional UI language packs, please open an issue on GitHub.':
'Для запроса дополнительных языковых пакетов интерфейса, пожалуйста, создайте обращение на GitHub.', 'Для запроса дополнительных языковых пакетов интерфейса, пожалуйста, создайте обращение на GitHub.',
'Available options:': 'Доступные варианты:', 'Available options:': 'Доступные варианты:',
' - zh-CN: Simplified Chinese': ' - zh-CN: Упрощенный китайский', ' - zh-CN: Simplified Chinese': ' - zh-CN: Упрощенный китайский',
' - en-US: English': ' - en-US: Английский', ' - en-US: English': ' - en-US: Английский',
' - ru-RU: Russian': ' - ru-RU: Русский',
'Set UI language to Simplified Chinese (zh-CN)': 'Set UI language to Simplified Chinese (zh-CN)':
'Установить язык интерфейса на упрощенный китайский (zh-CN)', 'Установить язык интерфейса на упрощенный китайский (zh-CN)',
'Set UI language to English (en-US)': 'Set UI language to English (en-US)':
@@ -435,8 +437,8 @@ export default {
'Режим подтверждения изменен на: {{mode}}', 'Режим подтверждения изменен на: {{mode}}',
'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})':
'Режим подтверждения изменен на: {{mode}} (сохранено в настройках {{scope}}{{location}})', 'Режим подтверждения изменен на: {{mode}} (сохранено в настройках {{scope}}{{location}})',
'Usage: /approval-mode [--session|--user|--project]': 'Usage: /approval-mode <mode> [--session|--user|--project]':
'Использование: /approval-mode [--session|--user|--project]', 'Использование: /approval-mode <mode> [--session|--user|--project]',
'Scope subcommands do not accept additional arguments.': 'Scope subcommands do not accept additional arguments.':
'Подкоманды области не принимают дополнительных аргументов.', 'Подкоманды области не принимают дополнительных аргументов.',
'Plan mode - Analyze only, do not modify files or execute commands': 'Plan mode - Analyze only, do not modify files or execute commands':
@@ -588,8 +590,8 @@ export default {
'Ошибка при экспорте диалога: {{error}}', 'Ошибка при экспорте диалога: {{error}}',
'Conversation shared to {{filePath}}': 'Диалог экспортирован в {{filePath}}', 'Conversation shared to {{filePath}}': 'Диалог экспортирован в {{filePath}}',
'No conversation found to share.': 'Нет диалога для экспорта.', 'No conversation found to share.': 'Нет диалога для экспорта.',
'Share the current conversation to a markdown or json file. Usage: /chat share <путь-к-файлу>': 'Share the current conversation to a markdown or json file. Usage: /chat share <file>':
'Экспортировать текущий диалог в markdown или json файл. Использование: /chat share <путь-к-файлу>', 'Экспортировать текущий диалог в markdown или json файл. Использование: /chat share <файл>',
// ============================================================================ // ============================================================================
// Команды - Резюме // Команды - Резюме
@@ -604,6 +606,12 @@ export default {
'Не найдено диалогов для создания сводки.', 'Не найдено диалогов для создания сводки.',
'Failed to generate project context summary: {{error}}': 'Failed to generate project context summary: {{error}}':
'Не удалось сгенерировать сводку контекста проекта: {{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',
// ============================================================================ // ============================================================================
// Команды - Модель // Команды - Модель
@@ -618,8 +626,9 @@ export default {
// ============================================================================ // ============================================================================
// Команды - Очистка // Команды - Очистка
// ============================================================================ // ============================================================================
'Clearing terminal and resetting chat.': 'Очистка терминала и сброс чата.', 'Starting a new session, resetting chat, and clearing terminal.':
'Clearing terminal.': 'Очистка терминала.', 'Начало новой сессии, сброс чата и очистка терминала.',
'Starting a new session and clearing.': 'Начало новой сессии и очистка.',
// ============================================================================ // ============================================================================
// Команды - Сжатие // Команды - Сжатие
@@ -650,8 +659,8 @@ export default {
'Команда /directory add не поддерживается в ограничительных профилях песочницы. Пожалуйста, используйте --include-directories при запуске сессии.', 'Команда /directory add не поддерживается в ограничительных профилях песочницы. Пожалуйста, используйте --include-directories при запуске сессии.',
"Error adding '{{path}}': {{error}}": "Error adding '{{path}}': {{error}}":
"Ошибка при добавлении '{{path}}': {{error}}", "Ошибка при добавлении '{{path}}': {{error}}",
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}': 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}':
'Успешно добавлены файлы GEMINI.md из следующих директорий (если они есть):\n- {{directories}}', 'Успешно добавлены файлы QWEN.md из следующих директорий (если они есть):\n- {{directories}}',
'Error refreshing memory: {{error}}': 'Error refreshing memory: {{error}}':
'Ошибка при обновлении памяти: {{error}}', 'Ошибка при обновлении памяти: {{error}}',
'Successfully added directories:\n- {{directories}}': 'Successfully added directories:\n- {{directories}}':
@@ -884,6 +893,7 @@ export default {
// Экран выхода / Статистика // Экран выхода / Статистика
// ============================================================================ // ============================================================================
'Agent powering down. Goodbye!': 'Агент завершает работу. До свидания!', 'Agent powering down. Goodbye!': 'Агент завершает работу. До свидания!',
'To continue this session, run': 'Для продолжения этой сессии, выполните',
'Interaction Summary': 'Сводка взаимодействия', 'Interaction Summary': 'Сводка взаимодействия',
'Session ID:': 'ID сессии:', 'Session ID:': 'ID сессии:',
'Tool Calls:': 'Вызовы инструментов:', 'Tool Calls:': 'Вызовы инструментов:',
@@ -943,179 +953,140 @@ export default {
'Waiting for user confirmation...': 'Waiting for user confirmation...':
'Ожидание подтверждения от пользователя...', 'Ожидание подтверждения от пользователя...',
'(esc to cancel, {{time}})': '(esc для отмены, {{time}})', '(esc to cancel, {{time}})': '(esc для отмены, {{time}})',
"I'm Feeling Lucky": 'Мне повезёт!',
'Shipping awesomeness... ': 'Доставляем крутизну... ', // ============================================================================
'Painting the serifs back on...': 'Рисуем засечки на буквах...',
'Navigating the slime mold...': 'Пробираемся через слизевиков..', // ============================================================================
'Consulting the digital spirits...': 'Советуемся с цифровыми духами...', // Loading Phrases
'Reticulating splines...': 'Сглаживание сплайнов...', // ============================================================================
'Warming up the AI hamsters...': 'Разогреваем ИИ-хомячков...', WITTY_LOADING_PHRASES: [
'Asking the magic conch shell...': 'Спрашиваем волшебную ракушку...', 'Мне повезёт!',
'Generating witty retort...': 'Генерируем остроумный ответ...', 'Доставляем крутизну... ',
'Polishing the algorithms...': 'Полируем алгоритмы...', 'Рисуем засечки на буквах...',
"Don't rush perfection (or my code)...": 'Пробираемся через слизевиков..',
'Советуемся с цифровыми духами...',
'Сглаживание сплайнов...',
'Разогреваем ИИ-хомячков...',
'Спрашиваем волшебную ракушку...',
'Генерируем остроумный ответ...',
'Полируем алгоритмы...',
'Не торопите совершенство (или мой код)...', 'Не торопите совершенство (или мой код)...',
'Brewing fresh bytes...': 'Завариваем свежие байты...', 'Завариваем свежие байты...',
'Counting electrons...': 'Пересчитываем электроны...', 'Пересчитываем электроны...',
'Engaging cognitive processors...': 'Задействуем когнитивные процессоры...', 'Задействуем когнитивные процессоры...',
'Checking for syntax errors in the universe...':
'Ищем синтаксические ошибки во вселенной...', 'Ищем синтаксические ошибки во вселенной...',
'One moment, optimizing humor...': 'Секундочку, оптимизируем юмор...', 'Секундочку, оптимизируем юмор...',
'Shuffling punchlines...': 'Перетасовываем панчлайны...', 'Перетасовываем панчлайны...',
'Untangling neural nets...': 'Распутаваем нейросети...', 'Распутаваем нейросети...',
'Compiling brilliance...': 'Компилируем гениальность...', 'Компилируем гениальность...',
'Loading wit.exe...': 'Загружаем yumor.exe...', 'Загружаем yumor.exe...',
'Summoning the cloud of wisdom...': 'Призываем облако мудрости...', 'Призываем облако мудрости...',
'Preparing a witty response...': 'Готовим остроумный ответ...', 'Готовим остроумный ответ...',
"Just a sec, I'm debugging reality...": 'Секунду, идёт отладка реальности...', 'Секунду, идёт отладка реальности...',
'Confuzzling the options...': 'Запутываем варианты...', 'Запутываем варианты...',
'Tuning the cosmic frequencies...': 'Настраиваем космические частоты...', 'Настраиваем космические частоты...',
'Crafting a response worthy of your patience...':
'Создаем ответ, достойный вашего терпения...', 'Создаем ответ, достойный вашего терпения...',
'Compiling the 1s and 0s...': 'Компилируем единички и нолики...', 'Компилируем единички и нолики...',
'Resolving dependencies... and existential crises...':
'Разрешаем зависимости... и экзистенциальные кризисы...', 'Разрешаем зависимости... и экзистенциальные кризисы...',
'Defragmenting memories... both RAM and personal...':
'Дефрагментация памяти... и оперативной, и личной...', 'Дефрагментация памяти... и оперативной, и личной...',
'Rebooting the humor module...': 'Перезагрузка модуля юмора...', 'Перезагрузка модуля юмора...',
'Caching the essentials (mostly cat memes)...':
'Кэшируем самое важное (в основном мемы с котиками)...', 'Кэшируем самое важное (в основном мемы с котиками)...',
'Optimizing for ludicrous speed': 'Оптимизация для безумной скорости', 'Оптимизация для безумной скорости',
"Swapping bits... don't tell the bytes...":
'Меняем биты... только байтам не говорите...', 'Меняем биты... только байтам не говорите...',
'Garbage collecting... be right back...': 'Сборка мусора... скоро вернусь...', 'Сборка мусора... скоро вернусь...',
'Assembling the interwebs...': 'Сборка интернетов...', 'Сборка интернетов...',
'Converting coffee into code...': 'Превращаем кофе в код...', 'Превращаем кофе в код...',
'Updating the syntax for reality...': 'Обновляем синтаксис реальности...', 'Обновляем синтаксис реальности...',
'Rewiring the synapses...': 'Переподключаем синапсы...', 'Переподключаем синапсы...',
'Looking for a misplaced semicolon...': 'Ищем лишнюю точку с запятой...', 'Ищем лишнюю точку с запятой...',
"Greasin' the cogs of the machine...": 'Смазываем шестерёнки машины...', 'Смазываем шестерёнки машины...',
'Pre-heating the servers...': 'Разогреваем серверы...', 'Разогреваем серверы...',
'Calibrating the flux capacitor...': 'Калибруем потоковый накопитель...', 'Калибруем потоковый накопитель...',
'Engaging the improbability drive...': 'Включаем двигатель невероятности...', 'Включаем двигатель невероятности...',
'Channeling the Force...': 'Направляем Силу...', 'Направляем Силу...',
'Aligning the stars for optimal response...':
'Выравниваем звёзды для оптимального ответа...', 'Выравниваем звёзды для оптимального ответа...',
'So say we all...': 'Так скажем мы все...', 'Так скажем мы все...',
'Loading the next great idea...': 'Загрузка следующей великой идеи...', 'Загрузка следующей великой идеи...',
"Just a moment, I'm in the zone...": 'Минутку, я в потоке...', 'Минутку, я в потоке...',
'Preparing to dazzle you with brilliance...':
'Готовлюсь ослепить вас гениальностью...', 'Готовлюсь ослепить вас гениальностью...',
"Just a tick, I'm polishing my wit...": 'Секунду, полирую остроумие...', 'Секунду, полирую остроумие...',
"Hold tight, I'm crafting a masterpiece...": 'Держитесь, создаю шедевр...', 'Держитесь, создаю шедевр...',
"Just a jiffy, I'm debugging the universe...":
'Мигом, отлаживаю вселенную...', 'Мигом, отлаживаю вселенную...',
"Just a moment, I'm aligning the pixels...": 'Момент, выравниваю пиксели...', 'Момент, выравниваю пиксели...',
"Just a sec, I'm optimizing the humor...": 'Секунду, оптимизирую юмор...', 'Секунду, оптимизирую юмор...',
"Just a moment, I'm tuning the algorithms...":
'Момент, настраиваю алгоритмы...', 'Момент, настраиваю алгоритмы...',
'Warp speed engaged...': 'Варп-скорость включена...', 'Варп-прыжок активирован...',
'Mining for more Dilithium crystals...': 'Добываем кристаллы дилития...', 'Добываем кристаллы дилития...',
"Don't panic...": 'Без паники...', 'Без паники...',
'Following the white rabbit...': 'Следуем за белым кроликом...', 'Следуем за белым кроликом...',
'The truth is in here... somewhere...': 'Истина где-то здесь... внутри...', 'Истина где-то здесь... внутри...',
'Blowing on the cartridge...': 'Продуваем картридж...', 'Продуваем картридж...',
'Loading... Do a barrel roll!': 'Загрузка... Сделай бочку!', 'Загрузка... Сделай бочку!',
'Waiting for the respawn...': 'Ждем респауна...', 'Ждем респауна...',
'Finishing the Kessel Run in less than 12 parsecs...':
'Делаем Дугу Кесселя менее чем за 12 парсеков...', 'Делаем Дугу Кесселя менее чем за 12 парсеков...',
"The cake is not a lie, it's just still loading...":
'Тортик — не ложь, он просто ещё грузится...', 'Тортик — не ложь, он просто ещё грузится...',
'Fiddling with the character creation screen...':
'Возимся с экраном создания персонажа...', 'Возимся с экраном создания персонажа...',
"Just a moment, I'm finding the right meme...":
'Минутку, ищу подходящий мем...', 'Минутку, ищу подходящий мем...',
"Pressing 'A' to continue...": "Нажимаем 'A' для продолжения...", "Нажимаем 'A' для продолжения...",
'Herding digital cats...': 'Пасём цифровых котов...', 'Пасём цифровых котов...',
'Polishing the pixels...': 'Полируем пиксели...', 'Полируем пиксели...',
'Finding a suitable loading screen pun...':
'Ищем подходящий каламбур для экрана загрузки...', 'Ищем подходящий каламбур для экрана загрузки...',
'Distracting you with this witty phrase...':
'Отвлекаем вас этой остроумной фразой...', 'Отвлекаем вас этой остроумной фразой...',
'Almost there... probably...': 'Почти готово... вроде...', 'Почти готово... вроде...',
'Our hamsters are working as fast as they can...':
'Наши хомячки работают изо всех сил...', 'Наши хомячки работают изо всех сил...',
'Giving Cloudy a pat on the head...': 'Гладим Облачко по голове...', 'Гладим Облачко по голове...',
'Petting the cat...': 'Гладим кота...', 'Гладим кота...',
'Rickrolling my boss...': 'Рикроллим начальника...', 'Рикроллим начальника...',
'Never gonna give you up, never gonna let you down...':
'Never gonna give you up, never gonna let you down...', 'Never gonna give you up, never gonna let you down...',
'Slapping the bass...': 'Лабаем бас-гитару...', 'Лабаем бас-гитару...',
'Tasting the snozberries...': 'Пробуем снузберри на вкус...', 'Пробуем снузберри на вкус...',
"I'm going the distance, I'm going for speed...":
'Иду до конца, иду на скорость...', 'Иду до конца, иду на скорость...',
'Is this the real life? Is this just fantasy?...':
'Is this the real life? Is this just fantasy?...', 'Is this the real life? Is this just fantasy?...',
"I've got a good feeling about this...": 'У меня хорошее предчувствие...', 'У меня хорошее предчувствие...',
'Poking the bear...': 'Дразним медведя... (Не лезь...)', 'Дразним медведя... (Не лезь...)',
'Doing research on the latest memes...': 'Изучаем свежие мемы...', 'Изучаем свежие мемы...',
'Figuring out how to make this more witty...':
'Думаем, как сделать это остроумнее...', 'Думаем, как сделать это остроумнее...',
'Hmmm... let me think...': 'Хмм... дайте подумать...', 'Хмм... дайте подумать...',
'What do you call a fish with no eyes? A fsh...':
'Как называется бумеранг, который не возвращается? Палка...', 'Как называется бумеранг, который не возвращается? Палка...',
'Why did the computer go to therapy? It had too many bytes...':
'Почему компьютер простудился? Потому что оставил окна открытыми...', 'Почему компьютер простудился? Потому что оставил окна открытыми...',
"Why don't programmers like nature? It has too many bugs...":
'Почему программисты не любят гулять на улице? Там среда не настроена...', 'Почему программисты не любят гулять на улице? Там среда не настроена...',
'Why do programmers prefer dark mode? Because light attracts bugs...':
'Почему программисты предпочитают тёмную тему? Потому что в темноте не видно багов...', 'Почему программисты предпочитают тёмную тему? Потому что в темноте не видно багов...',
'Why did the developer go broke? Because they used up all their cache...':
'Почему разработчик разорился? Потому что потратил весь свой кэш...', 'Почему разработчик разорился? Потому что потратил весь свой кэш...',
"What can you do with a broken pencil? Nothing, it's pointless...":
'Что можно делать со сломанным карандашом? Ничего — он тупой...', 'Что можно делать со сломанным карандашом? Ничего — он тупой...',
'Applying percussive maintenance...': 'Провожу настройку методом тыка...', 'Провожу настройку методом тыка...',
'Searching for the correct USB orientation...':
'Ищем, какой стороной вставлять флешку...', 'Ищем, какой стороной вставлять флешку...',
'Ensuring the magic smoke stays inside the wires...':
'Следим, чтобы волшебный дым не вышел из проводов...', 'Следим, чтобы волшебный дым не вышел из проводов...',
'Rewriting in Rust for no particular reason...':
'Переписываем всё на Rust без особой причины...', 'Переписываем всё на Rust без особой причины...',
'Trying to exit Vim...': 'Пытаемся выйти из Vim...', 'Пытаемся выйти из Vim...',
'Spinning up the hamster wheel...': 'Раскручиваем колесо для хомяка...', 'Раскручиваем колесо для хомяка...',
"That's not a bug, it's an undocumented feature...": 'Это не баг, а фича...', 'Это не баг, а фича...',
'Engage.': 'Поехали!', 'Поехали!',
"I'll be back... with an answer.": 'Я вернусь... с ответом.', 'Я вернусь... с ответом.',
'My other process is a TARDIS...': 'Мой другой процесс — это ТАРДИС...', 'Мой другой процесс — это ТАРДИС...',
'Communing with the machine spirit...': 'Общаемся с духом машины...', 'Общаемся с духом машины...',
'Letting the thoughts marinate...': 'Даем мыслям замариноваться...', 'Даем мыслям замариноваться...',
'Just remembered where I put my keys...':
'Только что вспомнил, куда положил ключи...', 'Только что вспомнил, куда положил ключи...',
'Pondering the orb...': 'Размышляю над сферой...', 'Размышляю над сферой...',
"I've seen things you people wouldn't believe... like a user who reads loading messages.": 'Я видел такое, что вам, людям, и не снилось... пользователя, читающего эти сообщения.',
'Я видел такое, во что вы, люди, просто не поверите... например, пользователя, читающего сообщения загрузки.', 'Инициируем задумчивый взгляд...',
'Initiating thoughtful gaze...': 'Инициируем задумчивый взгляд...',
"What's a computer's favorite snack? Microchips.":
'Что сервер заказывает в баре? Пинг-коладу.', 'Что сервер заказывает в баре? Пинг-коладу.',
"Why do Java developers wear glasses? Because they don't C#.":
'Почему Java-разработчики не убираются дома? Они ждут сборщик мусора...', 'Почему Java-разработчики не убираются дома? Они ждут сборщик мусора...',
'Charging the laser... pew pew!': 'Заряжаем лазер... пиу-пиу!', 'Заряжаем лазер... пиу-пиу!',
'Dividing by zero... just kidding!': 'Делим на ноль... шучу!', 'Делим на ноль... шучу!',
'Looking for an adult superviso... I mean, processing.':
'Ищу взрослых для присмот... в смысле, обрабатываю.', 'Ищу взрослых для присмот... в смысле, обрабатываю.',
'Making it go beep boop.': 'Делаем бип-буп.', 'Делаем бип-буп.',
'Buffering... because even AIs need a moment.': 'Буферизация... даже ИИ нужно время подумать.',
'Буферизация... даже ИИ нужно мгновение.',
'Entangling quantum particles for a faster response...':
'Запутываем квантовые частицы для быстрого ответа...', 'Запутываем квантовые частицы для быстрого ответа...',
'Polishing the chrome... on the algorithms.':
'Полируем хром... на алгоритмах.', 'Полируем хром... на алгоритмах.',
'Are you not entertained? (Working on it!)':
'Вы ещё не развлеклись?! Разве вы не за этим сюда пришли?!', 'Вы ещё не развлеклись?! Разве вы не за этим сюда пришли?!',
'Summoning the code gremlins... to help, of course.':
'Призываем гремлинов кода... для помощи, конечно же.', 'Призываем гремлинов кода... для помощи, конечно же.',
'Just waiting for the dial-up tone to finish...':
'Ждем, пока закончится звук dial-up модема...', 'Ждем, пока закончится звук dial-up модема...',
'Recalibrating the humor-o-meter.': 'Перекалибровка юморометра.', 'Перекалибровка юморометра.',
'My other loading screen is even funnier.':
'Мой другой экран загрузки ещё смешнее.', 'Мой другой экран загрузки ещё смешнее.',
"Pretty sure there's a cat walking on the keyboard somewhere...":
'Кажется, где-то по клавиатуре гуляет кот...', 'Кажется, где-то по клавиатуре гуляет кот...',
'Enhancing... Enhancing... Still loading.':
'Улучшаем... Ещё улучшаем... Всё ещё грузится.', 'Улучшаем... Ещё улучшаем... Всё ещё грузится.',
"It's not a bug, it's a feature... of this loading screen.":
'Это не баг, это фича... экрана загрузки.', 'Это не баг, это фича... экрана загрузки.',
'Have you tried turning it off and on again? (The loading screen, not me.)':
'Пробовали выключить и включить снова? (Экран загрузки, не меня!)', 'Пробовали выключить и включить снова? (Экран загрузки, не меня!)',
'Constructing additional pylons...': 'Нужно построить больше пилонов...', 'Нужно построить больше пилонов...',
],
}; };

View File

@@ -101,7 +101,7 @@ export default {
'Theme "{{themeName}}" not found.': '未找到主题 "{{themeName}}"。', 'Theme "{{themeName}}" not found.': '未找到主题 "{{themeName}}"。',
'Theme "{{themeName}}" not found in selected scope.': 'Theme "{{themeName}}" not found in selected scope.':
'在所选作用域中未找到主题 "{{themeName}}"。', '在所选作用域中未找到主题 "{{themeName}}"。',
'clear the screen and conversation history': '清屏并清除对话历史', 'Clear conversation history and free up context': '清除对话历史并释放上下文',
'Compresses the context by replacing it with a summary.': 'Compresses the context by replacing it with a summary.':
'通过用摘要替换来压缩上下文', '通过用摘要替换来压缩上下文',
'open full Qwen Code documentation in your browser': 'open full Qwen Code documentation in your browser':
@@ -249,6 +249,8 @@ export default {
', Tab to change focus': 'Tab 切换焦点', ', Tab to change focus': 'Tab 切换焦点',
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
'要查看更改,必须重启 Qwen Code。按 r 退出并立即应用更改。', '要查看更改,必须重启 Qwen Code。按 r 退出并立即应用更改。',
'The command "/{{command}}" is not supported in non-interactive mode.':
'不支持在非交互模式下使用命令 "/{{command}}"。',
// ============================================================================ // ============================================================================
// Settings Labels // Settings Labels
// ============================================================================ // ============================================================================
@@ -560,6 +562,12 @@ export default {
'No conversation found to summarize.': '未找到要总结的对话', 'No conversation found to summarize.': '未找到要总结的对话',
'Failed to generate project context summary: {{error}}': 'Failed to generate project context summary: {{error}}':
'生成项目上下文摘要失败:{{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 响应中接收到文本内容',
// ============================================================================ // ============================================================================
// Commands - Model // Commands - Model
@@ -573,8 +581,9 @@ export default {
// ============================================================================ // ============================================================================
// Commands - Clear // Commands - Clear
// ============================================================================ // ============================================================================
'Clearing terminal and resetting chat.': '正在清屏并重置聊天', 'Starting a new session, resetting chat, and clearing terminal.':
'Clearing terminal.': '正在清屏', '正在开始新会话,重置聊天并清屏',
'Starting a new session and clearing.': '正在开始新会话并清屏。',
// ============================================================================ // ============================================================================
// Commands - Compress // Commands - Compress
@@ -880,165 +889,39 @@ export default {
// ============================================================================ // ============================================================================
'Waiting for user confirmation...': '等待用户确认...', 'Waiting for user confirmation...': '等待用户确认...',
'(esc to cancel, {{time}})': '(按 esc 取消,{{time}}', '(esc to cancel, {{time}})': '(按 esc 取消,{{time}}',
"I'm Feeling Lucky": '我感觉很幸运', WITTY_LOADING_PHRASES: [
'Shipping awesomeness... ': '正在运送精彩内容... ', // --- 职场搬砖系列 ---
'Painting the serifs back on...': '正在重新绘制衬线...', '正在努力搬砖,请稍候...',
'Navigating the slime mold...': '正在导航粘液霉菌...', '老板在身后,快加载啊!',
'Consulting the digital spirits...': '正在咨询数字精灵...', '头发掉光前,一定能加载完...',
'Reticulating splines...': '正在网格化样条曲线...', '服务器正在深呼吸,准备放大招...',
'Warming up the AI hamsters...': '正在预热 AI 仓鼠...', '正在向服务器投喂咖啡...',
'Asking the magic conch shell...': '正在询问魔法海螺壳...',
'Generating witty retort...': '正在生成机智的反驳...', // --- 大厂黑话系列 ---
'Polishing the algorithms...': '正在打磨算法...', '正在赋能全链路,寻找关键抓手...',
"Don't rush perfection (or my code)...": '不要急于追求完美(或我的代码)...', '正在降本增效,优化加载路径...',
'Brewing fresh bytes...': '正在酿造新鲜字节...', '正在打破部门壁垒,沉淀方法论...',
'Counting electrons...': '正在计算电子...', '正在拥抱变化,迭代核心价值...',
'Engaging cognitive processors...': '正在启动认知处理器...', '正在对齐颗粒度,打磨底层逻辑...',
'Checking for syntax errors in the universe...': '大力出奇迹,正在强行加载...',
'正在检查宇宙中的语法错误...',
'One moment, optimizing humor...': '稍等片刻,正在优化幽默感...', // --- 程序员自嘲系列 ---
'Shuffling punchlines...': '正在洗牌笑点...', '只要我不写代码,代码就没有 Bug...',
'Untangling neural nets...': '正在解开神经网络...', '正在把 Bug 转化为 Feature...',
'Compiling brilliance...': '正在编译智慧...', '只要我不尴尬Bug 就追不上我...',
'Loading wit.exe...': '正在加载 wit.exe...', '正在试图理解去年的自己写了什么...',
'Summoning the cloud of wisdom...': '正在召唤智慧云...', '正在猿力觉醒中,请耐心等待...',
'Preparing a witty response...': '正在准备机智的回复...',
"Just a sec, I'm debugging reality...": '稍等片刻,我正在调试现实...', // --- 合作愉快系列 ---
'Confuzzling the options...': '正在混淆选项...', '正在询问产品经理:这需求是真的吗?',
'Tuning the cosmic frequencies...': '正在调谐宇宙频率...', '正在给产品经理画饼,请稍等...',
'Crafting a response worthy of your patience...':
'正在制作值得您耐心等待的回复...', // --- 温暖治愈系列 ---
'Compiling the 1s and 0s...': '正在编译 1 和 0...', '每一行代码,都在努力让世界变得更好一点点...',
'Resolving dependencies... and existential crises...': '每一个伟大的想法,都值得这份耐心的等待...',
'正在解决依赖关系...和存在主义危机...', '别急,美好的事物总是需要一点时间去酝酿...',
'Defragmenting memories... both RAM and personal...': '愿你的代码永无 Bug愿你的梦想终将成真...',
'正在整理记忆碎片...包括 RAM 和个人记忆...', '哪怕只有 0.1% 的进度,也是在向目标靠近...',
'Rebooting the humor module...': '正在重启幽默模块...', '加载的是字节,承载的是对技术的热爱...',
'Caching the essentials (mostly cat memes)...': ],
'正在缓存必需品(主要是猫咪表情包)...',
'Optimizing for ludicrous speed': '正在优化到荒谬的速度',
"Swapping bits... don't tell the bytes...": '正在交换位...不要告诉字节...',
'Garbage collecting... be right back...': '正在垃圾回收...马上回来...',
'Assembling the interwebs...': '正在组装互联网...',
'Converting coffee into code...': '正在将咖啡转换为代码...',
'Updating the syntax for reality...': '正在更新现实的语法...',
'Rewiring the synapses...': '正在重新连接突触...',
'Looking for a misplaced semicolon...': '正在寻找放错位置的分号...',
"Greasin' the cogs of the machine...": '正在给机器的齿轮上油...',
'Pre-heating the servers...': '正在预热服务器...',
'Calibrating the flux capacitor...': '正在校准通量电容器...',
'Engaging the improbability drive...': '正在启动不可能性驱动器...',
'Channeling the Force...': '正在引导原力...',
'Aligning the stars for optimal response...': '正在对齐星星以获得最佳回复...',
'So say we all...': '我们都说...',
'Loading the next great idea...': '正在加载下一个伟大的想法...',
"Just a moment, I'm in the zone...": '稍等片刻,我正进入状态...',
'Preparing to dazzle you with brilliance...': '正在准备用智慧让您眼花缭乱...',
"Just a tick, I'm polishing my wit...": '稍等片刻,我正在打磨我的智慧...',
"Hold tight, I'm crafting a masterpiece...": '请稍等,我正在制作杰作...',
"Just a jiffy, I'm debugging the universe...": '稍等片刻,我正在调试宇宙...',
"Just a moment, I'm aligning the pixels...": '稍等片刻,我正在对齐像素...',
"Just a sec, I'm optimizing the humor...": '稍等片刻,我正在优化幽默感...',
"Just a moment, I'm tuning the algorithms...": '稍等片刻,我正在调整算法...',
'Warp speed engaged...': '曲速已启动...',
'Mining for more Dilithium crystals...': '正在挖掘更多二锂晶体...',
"Don't panic...": '不要惊慌...',
'Following the white rabbit...': '正在跟随白兔...',
'The truth is in here... somewhere...': '真相在这里...某个地方...',
'Blowing on the cartridge...': '正在吹卡带...',
'Loading... Do a barrel roll!': '正在加载...做个桶滚!',
'Waiting for the respawn...': '等待重生...',
'Finishing the Kessel Run in less than 12 parsecs...':
'正在以不到 12 秒差距完成凯塞尔航线...',
"The cake is not a lie, it's just still loading...":
'蛋糕不是谎言,只是还在加载...',
'Fiddling with the character creation screen...': '正在摆弄角色创建界面...',
"Just a moment, I'm finding the right meme...":
'稍等片刻,我正在寻找合适的表情包...',
"Pressing 'A' to continue...": "按 'A' 继续...",
'Herding digital cats...': '正在放牧数字猫...',
'Polishing the pixels...': '正在打磨像素...',
'Finding a suitable loading screen pun...': '正在寻找合适的加载屏幕双关语...',
'Distracting you with this witty phrase...':
'正在用这个机智的短语分散您的注意力...',
'Almost there... probably...': '快到了...可能...',
'Our hamsters are working as fast as they can...':
'我们的仓鼠正在尽可能快地工作...',
'Giving Cloudy a pat on the head...': '正在拍拍 Cloudy 的头...',
'Petting the cat...': '正在抚摸猫咪...',
'Rickrolling my boss...': '正在 Rickroll 我的老板...',
'Never gonna give you up, never gonna let you down...':
'永远不会放弃你,永远不会让你失望...',
'Slapping the bass...': '正在拍打低音...',
'Tasting the snozberries...': '正在品尝 snozberries...',
"I'm going the distance, I'm going for speed...":
'我要走得更远,我要追求速度...',
'Is this the real life? Is this just fantasy?...':
'这是真实的生活吗?还是只是幻想?...',
"I've got a good feeling about this...": '我对这个感觉很好...',
'Poking the bear...': '正在戳熊...',
'Doing research on the latest memes...': '正在研究最新的表情包...',
'Figuring out how to make this more witty...': '正在想办法让这更有趣...',
'Hmmm... let me think...': '嗯...让我想想...',
'What do you call a fish with no eyes? A fsh...':
'没有眼睛的鱼叫什么?一条鱼...',
'Why did the computer go to therapy? It had too many bytes...':
'为什么电脑去看心理医生?因为它有太多字节...',
"Why don't programmers like nature? It has too many bugs...":
'为什么程序员不喜欢大自然?因为虫子太多了...',
'Why do programmers prefer dark mode? Because light attracts bugs...':
'为什么程序员喜欢暗色模式?因为光会吸引虫子...',
'Why did the developer go broke? Because they used up all their cache...':
'为什么开发者破产了?因为他们用完了所有缓存...',
"What can you do with a broken pencil? Nothing, it's pointless...":
'你能用断了的铅笔做什么?什么都不能,因为它没有笔尖...',
'Applying percussive maintenance...': '正在应用敲击维护...',
'Searching for the correct USB orientation...': '正在寻找正确的 USB 方向...',
'Ensuring the magic smoke stays inside the wires...':
'确保魔法烟雾留在电线内...',
'Rewriting in Rust for no particular reason...':
'正在用 Rust 重写,没有特别的原因...',
'Trying to exit Vim...': '正在尝试退出 Vim...',
'Spinning up the hamster wheel...': '正在启动仓鼠轮...',
"That's not a bug, it's an undocumented feature...":
'这不是一个错误,这是一个未记录的功能...',
'Engage.': '启动。',
"I'll be back... with an answer.": '我会回来的...带着答案。',
'My other process is a TARDIS...': '我的另一个进程是 TARDIS...',
'Communing with the machine spirit...': '正在与机器精神交流...',
'Letting the thoughts marinate...': '让想法慢慢酝酿...',
'Just remembered where I put my keys...': '刚刚想起我把钥匙放在哪里了...',
'Pondering the orb...': '正在思考球体...',
"I've seen things you people wouldn't believe... like a user who reads loading messages.":
'我见过你们不会相信的事情...比如一个阅读加载消息的用户。',
'Initiating thoughtful gaze...': '正在启动深思凝视...',
"What's a computer's favorite snack? Microchips.":
'电脑最喜欢的零食是什么?微芯片。',
"Why do Java developers wear glasses? Because they don't C#.":
'为什么 Java 开发者戴眼镜?因为他们不会 C#。',
'Charging the laser... pew pew!': '正在给激光充电...砰砰!',
'Dividing by zero... just kidding!': '除以零...只是开玩笑!',
'Looking for an adult superviso... I mean, processing.':
'正在寻找成人监督...我是说,处理中。',
'Making it go beep boop.': '让它发出哔哔声。',
'Buffering... because even AIs need a moment.':
'正在缓冲...因为即使是 AI 也需要片刻。',
'Entangling quantum particles for a faster response...':
'正在纠缠量子粒子以获得更快的回复...',
'Polishing the chrome... on the algorithms.': '正在打磨铬...在算法上。',
'Are you not entertained? (Working on it!)': '你不觉得有趣吗?(正在努力!)',
'Summoning the code gremlins... to help, of course.':
'正在召唤代码小精灵...当然是来帮忙的。',
'Just waiting for the dial-up tone to finish...': '只是等待拨号音结束...',
'Recalibrating the humor-o-meter.': '正在重新校准幽默计。',
'My other loading screen is even funnier.': '我的另一个加载屏幕更有趣。',
"Pretty sure there's a cat walking on the keyboard somewhere...":
'很确定有只猫在某个地方键盘上走...',
'Enhancing... Enhancing... Still loading.':
'正在增强...正在增强...仍在加载。',
"It's not a bug, it's a feature... of this loading screen.":
'这不是一个错误,这是一个功能...这个加载屏幕的功能。',
'Have you tried turning it off and on again? (The loading screen, not me.)':
'你试过把它关掉再打开吗?(加载屏幕,不是我。)',
'Constructing additional pylons...': '正在建造额外的能量塔...',
}; };

View File

@@ -20,8 +20,7 @@ import type {
CLIControlSetModelRequest, CLIControlSetModelRequest,
CLIMcpServerConfig, CLIMcpServerConfig,
} from '../../types.js'; } from '../../types.js';
import { CommandService } from '../../../services/CommandService.js'; import { getAvailableCommands } from '../../../nonInteractiveCliCommands.js';
import { BuiltinCommandLoader } from '../../../services/BuiltinCommandLoader.js';
import { import {
MCPServerConfig, MCPServerConfig,
AuthProviderType, AuthProviderType,
@@ -407,7 +406,7 @@ export class SystemController extends BaseController {
} }
/** /**
* Load slash command names using CommandService * Load slash command names using getAvailableCommands
* *
* @param signal - AbortSignal to respect for cancellation * @param signal - AbortSignal to respect for cancellation
* @returns Promise resolving to array of slash command names * @returns Promise resolving to array of slash command names
@@ -418,21 +417,14 @@ export class SystemController extends BaseController {
} }
try { try {
const service = await CommandService.create( const commands = await getAvailableCommands(this.context.config, signal);
[new BuiltinCommandLoader(this.context.config)],
signal,
);
if (signal.aborted) { if (signal.aborted) {
return []; return [];
} }
const names = new Set<string>(); // Extract command names and sort
const commands = service.getCommands(); return commands.map((cmd) => cmd.name).sort();
for (const command of commands) {
names.add(command.name);
}
return Array.from(names).sort();
} catch (error) { } catch (error) {
// Check if the error is due to abort // Check if the error is due to abort
if (signal.aborted) { if (signal.aborted) {

View File

@@ -68,6 +68,7 @@ describe('runNonInteractive', () => {
let mockShutdownTelemetry: Mock; let mockShutdownTelemetry: Mock;
let consoleErrorSpy: MockInstance; let consoleErrorSpy: MockInstance;
let processStdoutSpy: MockInstance; let processStdoutSpy: MockInstance;
let processStderrSpy: MockInstance;
let mockGeminiClient: { let mockGeminiClient: {
sendMessageStream: Mock; sendMessageStream: Mock;
getChatRecordingService: Mock; getChatRecordingService: Mock;
@@ -86,6 +87,9 @@ describe('runNonInteractive', () => {
processStdoutSpy = vi processStdoutSpy = vi
.spyOn(process.stdout, 'write') .spyOn(process.stdout, 'write')
.mockImplementation(() => true); .mockImplementation(() => true);
processStderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
vi.spyOn(process, 'exit').mockImplementation((code) => { vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit(${code}) called`); throw new Error(`process.exit(${code}) called`);
}); });
@@ -139,6 +143,8 @@ describe('runNonInteractive', () => {
setModel: vi.fn(async (model: string) => { setModel: vi.fn(async (model: string) => {
currentModel = model; currentModel = model;
}), }),
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(false),
} as unknown as Config; } as unknown as Config;
mockSettings = { mockSettings = {
@@ -765,6 +771,52 @@ describe('runNonInteractive', () => {
); );
}); });
it('should handle API errors in text mode and exit with error code', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.TEXT);
setupMetricsMock();
// Simulate an API error event (like 401 unauthorized)
const apiErrorEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.Error,
value: {
error: {
message: '401 Incorrect API key provided',
status: 401,
},
},
};
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents([apiErrorEvent]),
);
let thrownError: Error | null = null;
try {
await runNonInteractive(
mockConfig,
mockSettings,
'Test input',
'prompt-id-api-error',
);
// Should not reach here
expect.fail('Expected error to be thrown');
} catch (error) {
thrownError = error as Error;
}
// Should throw with the API error message
expect(thrownError).toBeTruthy();
expect(thrownError?.message).toContain('401');
expect(thrownError?.message).toContain('Incorrect API key provided');
// Verify error was written to stderr
expect(processStderrSpy).toHaveBeenCalled();
const stderrCalls = processStderrSpy.mock.calls;
const errorOutput = stderrCalls.map((call) => call[0]).join('');
expect(errorOutput).toContain('401');
expect(errorOutput).toContain('Incorrect API key provided');
});
it('should handle FatalInputError with custom exit code in JSON format', async () => { it('should handle FatalInputError with custom exit code in JSON format', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON);
setupMetricsMock(); setupMetricsMock();
@@ -852,7 +904,7 @@ describe('runNonInteractive', () => {
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command'); expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
}); });
it('should throw FatalInputError if a command requires confirmation', async () => { it('should handle command that requires confirmation by returning early', async () => {
const mockCommand = { const mockCommand = {
name: 'confirm', name: 'confirm',
description: 'a command that needs confirmation', description: 'a command that needs confirmation',
@@ -864,15 +916,16 @@ describe('runNonInteractive', () => {
}; };
mockGetCommands.mockReturnValue([mockCommand]); mockGetCommands.mockReturnValue([mockCommand]);
await expect( await runNonInteractive(
runNonInteractive( mockConfig,
mockConfig, mockSettings,
mockSettings, '/confirm',
'/confirm', 'prompt-id-confirm',
'prompt-id-confirm', );
),
).rejects.toThrow( // Should write error message to stderr
'Exiting due to a confirmation prompt requested by the command.', expect(processStderrSpy).toHaveBeenCalledWith(
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.\n',
); );
}); });
@@ -909,7 +962,30 @@ describe('runNonInteractive', () => {
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown'); expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
}); });
it('should throw for unhandled command result types', async () => { it('should handle known but unsupported slash commands like /help by returning early', async () => {
// Mock a built-in command that exists but is not in the allowed list
const mockHelpCommand = {
name: 'help',
description: 'Show help',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
};
mockGetCommands.mockReturnValue([mockHelpCommand]);
await runNonInteractive(
mockConfig,
mockSettings,
'/help',
'prompt-id-help',
);
// Should write error message to stderr
expect(processStderrSpy).toHaveBeenCalledWith(
'The command "/help" is not supported in non-interactive mode.\n',
);
});
it('should handle unhandled command result types by returning early with error', async () => {
const mockCommand = { const mockCommand = {
name: 'noaction', name: 'noaction',
description: 'unhandled type', description: 'unhandled type',
@@ -920,15 +996,16 @@ describe('runNonInteractive', () => {
}; };
mockGetCommands.mockReturnValue([mockCommand]); mockGetCommands.mockReturnValue([mockCommand]);
await expect( await runNonInteractive(
runNonInteractive( mockConfig,
mockConfig, mockSettings,
mockSettings, '/noaction',
'/noaction', 'prompt-id-unhandled',
'prompt-id-unhandled', );
),
).rejects.toThrow( // Should write error message to stderr
'Exiting due to command result that is not supported in non-interactive mode.', expect(processStderrSpy).toHaveBeenCalledWith(
'Unknown command result type: unhandled\n',
); );
}); });

View File

@@ -42,6 +42,55 @@ import {
computeUsageFromMetrics, computeUsageFromMetrics,
} from './utils/nonInteractiveHelpers.js'; } from './utils/nonInteractiveHelpers.js';
/**
* Emits a final message for slash command results.
* Note: systemMessage should already be emitted before calling this function.
*/
async function emitNonInteractiveFinalMessage(params: {
message: string;
isError: boolean;
adapter?: JsonOutputAdapterInterface;
config: Config;
startTimeMs: number;
}): Promise<void> {
const { message, isError, adapter, config } = params;
if (!adapter) {
// Text output mode: write directly to stdout/stderr
const target = isError ? process.stderr : process.stdout;
target.write(`${message}\n`);
return;
}
// JSON output mode: emit assistant message and result
// (systemMessage should already be emitted by caller)
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: message,
} as unknown as Parameters<JsonOutputAdapterInterface['processEvent']>[0]);
adapter.finalizeAssistantMessage();
const metrics = uiTelemetryService.getMetrics();
const usage = computeUsageFromMetrics(metrics);
const outputFormat = config.getOutputFormat();
const stats =
outputFormat === OutputFormat.JSON
? uiTelemetryService.getMetrics()
: undefined;
adapter.emitResult({
isError,
durationMs: Date.now() - params.startTimeMs,
apiDurationMs: 0,
numTurns: 0,
errorMessage: isError ? message : undefined,
usage,
stats,
summary: message,
});
}
/** /**
* Provides optional overrides for `runNonInteractive` execution. * Provides optional overrides for `runNonInteractive` execution.
* *
@@ -115,6 +164,16 @@ export async function runNonInteractive(
process.on('SIGINT', shutdownHandler); process.on('SIGINT', shutdownHandler);
process.on('SIGTERM', shutdownHandler); process.on('SIGTERM', shutdownHandler);
// Emit systemMessage first (always the first message in JSON mode)
if (adapter) {
const systemMessage = await buildSystemMessage(
config,
sessionId,
permissionMode,
);
adapter.emitMessage(systemMessage);
}
let initialPartList: PartListUnion | null = extractPartsFromUserMessage( let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
options.userMessage, options.userMessage,
); );
@@ -128,10 +187,45 @@ export async function runNonInteractive(
config, config,
settings, settings,
); );
if (slashCommandResult) { switch (slashCommandResult.type) {
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise. case 'submit_prompt':
initialPartList = slashCommandResult as PartListUnion; // A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
slashHandled = true; initialPartList = slashCommandResult.content;
slashHandled = true;
break;
case 'message': {
// systemMessage already emitted above
await emitNonInteractiveFinalMessage({
message: slashCommandResult.content,
isError: slashCommandResult.messageType === 'error',
adapter,
config,
startTimeMs: startTime,
});
return;
}
case 'stream_messages':
throw new FatalInputError(
'Stream messages mode is not supported in non-interactive CLI',
);
case 'unsupported': {
await emitNonInteractiveFinalMessage({
message: slashCommandResult.reason,
isError: true,
adapter,
config,
startTimeMs: startTime,
});
return;
}
case 'no_command':
break;
default: {
const _exhaustive: never = slashCommandResult;
throw new FatalInputError(
`Unhandled slash command result type: ${(_exhaustive as { type: string }).type}`,
);
}
} }
} }
@@ -163,15 +257,6 @@ export async function runNonInteractive(
const initialParts = normalizePartList(initialPartList); const initialParts = normalizePartList(initialPartList);
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }]; let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
if (adapter) {
const systemMessage = await buildSystemMessage(
config,
sessionId,
permissionMode,
);
adapter.emitMessage(systemMessage);
}
let isFirstTurn = true; let isFirstTurn = true;
while (true) { while (true) {
turnCount++; turnCount++;
@@ -223,6 +308,8 @@ export async function runNonInteractive(
config.getContentGeneratorConfig()?.authType, config.getContentGeneratorConfig()?.authType,
); );
process.stderr.write(`${errorText}\n`); process.stderr.write(`${errorText}\n`);
// Throw error to exit with non-zero code
throw new Error(errorText);
} }
} }
} }

View File

@@ -0,0 +1,242 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
import type { Config } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from './config/settings.js';
import { CommandKind } from './ui/commands/types.js';
// Mock the CommandService
const mockGetCommands = vi.hoisted(() => vi.fn());
const mockCommandServiceCreate = vi.hoisted(() => vi.fn());
vi.mock('./services/CommandService.js', () => ({
CommandService: {
create: mockCommandServiceCreate,
},
}));
describe('handleSlashCommand', () => {
let mockConfig: Config;
let mockSettings: LoadedSettings;
let abortController: AbortController;
beforeEach(() => {
mockCommandServiceCreate.mockResolvedValue({
getCommands: mockGetCommands,
});
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'),
storage: {},
} as unknown as Config;
mockSettings = {
system: { path: '', settings: {} },
systemDefaults: { path: '', settings: {} },
user: { path: '', settings: {} },
workspace: { path: '', settings: {} },
} as LoadedSettings;
abortController = new AbortController();
});
it('should return no_command for non-slash input', async () => {
const result = await handleSlashCommand(
'regular text',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('no_command');
});
it('should return no_command for unknown slash commands', async () => {
mockGetCommands.mockReturnValue([]);
const result = await handleSlashCommand(
'/unknowncommand',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('no_command');
});
it('should return unsupported for known built-in commands not in allowed list', async () => {
const mockHelpCommand = {
name: 'help',
description: 'Show help',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
};
mockGetCommands.mockReturnValue([mockHelpCommand]);
const result = await handleSlashCommand(
'/help',
abortController,
mockConfig,
mockSettings,
[], // Empty allowed list
);
expect(result.type).toBe('unsupported');
if (result.type === 'unsupported') {
expect(result.reason).toContain('/help');
expect(result.reason).toContain('not supported');
}
});
it('should return unsupported for /help when using default allowed list', async () => {
const mockHelpCommand = {
name: 'help',
description: 'Show help',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
};
mockGetCommands.mockReturnValue([mockHelpCommand]);
const result = await handleSlashCommand(
'/help',
abortController,
mockConfig,
mockSettings,
// Default allowed list: ['init', 'summary', 'compress']
);
expect(result.type).toBe('unsupported');
if (result.type === 'unsupported') {
expect(result.reason).toBe(
'The command "/help" is not supported in non-interactive mode.',
);
}
});
it('should execute allowed built-in commands', async () => {
const mockInitCommand = {
name: 'init',
description: 'Initialize project',
kind: CommandKind.BUILT_IN,
action: vi.fn().mockResolvedValue({
type: 'message',
messageType: 'info',
content: 'Project initialized',
}),
};
mockGetCommands.mockReturnValue([mockInitCommand]);
const result = await handleSlashCommand(
'/init',
abortController,
mockConfig,
mockSettings,
['init'], // init is in the allowed list
);
expect(result.type).toBe('message');
if (result.type === 'message') {
expect(result.content).toBe('Project initialized');
}
});
it('should execute file commands regardless of allowed list', async () => {
const mockFileCommand = {
name: 'custom',
description: 'Custom file command',
kind: CommandKind.FILE,
action: vi.fn().mockResolvedValue({
type: 'submit_prompt',
content: [{ text: 'Custom prompt' }],
}),
};
mockGetCommands.mockReturnValue([mockFileCommand]);
const result = await handleSlashCommand(
'/custom',
abortController,
mockConfig,
mockSettings,
[], // Empty allowed list, but FILE commands should still work
);
expect(result.type).toBe('submit_prompt');
if (result.type === 'submit_prompt') {
expect(result.content).toEqual([{ text: 'Custom prompt' }]);
}
});
it('should return unsupported for other built-in commands like /quit', async () => {
const mockQuitCommand = {
name: 'quit',
description: 'Quit application',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
};
mockGetCommands.mockReturnValue([mockQuitCommand]);
const result = await handleSlashCommand(
'/quit',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('unsupported');
if (result.type === 'unsupported') {
expect(result.reason).toContain('/quit');
expect(result.reason).toContain('not supported');
}
});
it('should handle command with no action', async () => {
const mockCommand = {
name: 'noaction',
description: 'Command without action',
kind: CommandKind.FILE,
// No action property
};
mockGetCommands.mockReturnValue([mockCommand]);
const result = await handleSlashCommand(
'/noaction',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('no_command');
});
it('should return message when command returns void', async () => {
const mockCommand = {
name: 'voidcmd',
description: 'Command that returns void',
kind: CommandKind.FILE,
action: vi.fn().mockResolvedValue(undefined),
};
mockGetCommands.mockReturnValue([mockCommand]);
const result = await handleSlashCommand(
'/voidcmd',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('message');
if (result.type === 'message') {
expect(result.content).toBe('Command executed successfully.');
expect(result.messageType).toBe('info');
}
});
});

View File

@@ -7,7 +7,6 @@
import type { PartListUnion } from '@google/genai'; import type { PartListUnion } from '@google/genai';
import { parseSlashCommand } from './utils/commands.js'; import { parseSlashCommand } from './utils/commands.js';
import { import {
FatalInputError,
Logger, Logger,
uiTelemetryService, uiTelemetryService,
type Config, type Config,
@@ -19,10 +18,164 @@ import {
CommandKind, CommandKind,
type CommandContext, type CommandContext,
type SlashCommand, type SlashCommand,
type SlashCommandActionReturn,
} from './ui/commands/types.js'; } from './ui/commands/types.js';
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js'; import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
import type { LoadedSettings } from './config/settings.js'; import type { LoadedSettings } from './config/settings.js';
import type { SessionStatsState } from './ui/contexts/SessionContext.js'; import type { SessionStatsState } from './ui/contexts/SessionContext.js';
import { t } from './i18n/index.js';
/**
* Built-in commands that are allowed in non-interactive modes (CLI and ACP).
* Only safe, read-only commands that don't require interactive UI.
*
* These commands are:
* - init: Initialize project configuration
* - summary: Generate session summary
* - compress: Compress conversation history
*/
export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [
'init',
'summary',
'compress',
] as const;
/**
* Result of handling a slash command in non-interactive mode.
*
* Supported types:
* - 'submit_prompt': Submits content to the model (supports all modes)
* - 'message': Returns a single message (supports non-interactive JSON/text only)
* - 'stream_messages': Streams multiple messages (supports ACP only)
* - 'unsupported': Command cannot be executed in this mode
* - 'no_command': No command was found or executed
*/
export type NonInteractiveSlashCommandResult =
| {
type: 'submit_prompt';
content: PartListUnion;
}
| {
type: 'message';
messageType: 'info' | 'error';
content: string;
}
| {
type: 'stream_messages';
messages: AsyncGenerator<
{ messageType: 'info' | 'error'; content: string },
void,
unknown
>;
}
| {
type: 'unsupported';
reason: string;
originalType: string;
}
| {
type: 'no_command';
};
/**
* Converts a SlashCommandActionReturn to a NonInteractiveSlashCommandResult.
*
* Only the following result types are supported in non-interactive mode:
* - submit_prompt: Submits content to the model (all modes)
* - message: Returns a single message (non-interactive JSON/text only)
* - stream_messages: Streams multiple messages (ACP only)
*
* All other result types are converted to 'unsupported'.
*
* @param result The result from executing a slash command action
* @returns A NonInteractiveSlashCommandResult describing the outcome
*/
function handleCommandResult(
result: SlashCommandActionReturn,
): NonInteractiveSlashCommandResult {
switch (result.type) {
case 'submit_prompt':
return {
type: 'submit_prompt',
content: result.content,
};
case 'message':
return {
type: 'message',
messageType: result.messageType,
content: result.content,
};
case 'stream_messages':
return {
type: 'stream_messages',
messages: result.messages,
};
/**
* Currently return types below are never generated due to the
* whitelist of allowed slash commands in ACP and non-interactive mode.
* We'll try to add more supported return types in the future.
*/
case 'tool':
return {
type: 'unsupported',
reason:
'Tool execution from slash commands is not supported in non-interactive mode.',
originalType: 'tool',
};
case 'quit':
return {
type: 'unsupported',
reason:
'Quit command is not supported in non-interactive mode. The process will exit naturally after completion.',
originalType: 'quit',
};
case 'dialog':
return {
type: 'unsupported',
reason: `Dialog '${result.dialog}' cannot be opened in non-interactive mode.`,
originalType: 'dialog',
};
case 'load_history':
return {
type: 'unsupported',
reason:
'Loading history is not supported in non-interactive mode. Each invocation starts with a fresh context.',
originalType: 'load_history',
};
case 'confirm_shell_commands':
return {
type: 'unsupported',
reason:
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.',
originalType: 'confirm_shell_commands',
};
case 'confirm_action':
return {
type: 'unsupported',
reason:
'Action confirmation is not supported in non-interactive mode. Commands requiring confirmation cannot be executed.',
originalType: 'confirm_action',
};
default: {
// Exhaustiveness check
const _exhaustive: never = result;
return {
type: 'unsupported',
reason: `Unknown command result type: ${(_exhaustive as SlashCommandActionReturn).type}`,
originalType: 'unknown',
};
}
}
}
/** /**
* Filters commands based on the allowed built-in command names. * Filters commands based on the allowed built-in command names.
@@ -62,122 +215,146 @@ function filterCommandsForNonInteractive(
* @param config The configuration object * @param config The configuration object
* @param settings The loaded settings * @param settings The loaded settings
* @param allowedBuiltinCommandNames Optional array of built-in command names that are * @param allowedBuiltinCommandNames Optional array of built-in command names that are
* allowed. If not provided or empty, only file commands are available. * allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress).
* @returns A Promise that resolves to `PartListUnion` if a valid command is * Pass an empty array to only allow file commands.
* found and results in a prompt, or `undefined` otherwise. * @returns A Promise that resolves to a `NonInteractiveSlashCommandResult` describing
* @throws {FatalInputError} if the command result is not supported in * the outcome of the command execution.
* non-interactive mode.
*/ */
export const handleSlashCommand = async ( export const handleSlashCommand = async (
rawQuery: string, rawQuery: string,
abortController: AbortController, abortController: AbortController,
config: Config, config: Config,
settings: LoadedSettings, settings: LoadedSettings,
allowedBuiltinCommandNames?: string[], allowedBuiltinCommandNames: string[] = [
): Promise<PartListUnion | undefined> => { ...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
],
): Promise<NonInteractiveSlashCommandResult> => {
const trimmed = rawQuery.trim(); const trimmed = rawQuery.trim();
if (!trimmed.startsWith('/')) { if (!trimmed.startsWith('/')) {
return; return { type: 'no_command' };
} }
const isAcpMode = config.getExperimentalZedIntegration();
const isInteractive = config.isInteractive();
const executionMode = isAcpMode
? 'acp'
: isInteractive
? 'interactive'
: 'non_interactive';
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []); const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
// Only load BuiltinCommandLoader if there are allowed built-in commands // Load all commands to check if the command exists but is not allowed
const loaders = const allLoaders = [
allowedBuiltinSet.size > 0 new BuiltinCommandLoader(config),
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)] new FileCommandLoader(config),
: [new FileCommandLoader(config)]; ];
const commandService = await CommandService.create( const commandService = await CommandService.create(
loaders, allLoaders,
abortController.signal, abortController.signal,
); );
const commands = commandService.getCommands(); const allCommands = commandService.getCommands();
const filteredCommands = filterCommandsForNonInteractive( const filteredCommands = filterCommandsForNonInteractive(
commands, allCommands,
allowedBuiltinSet, allowedBuiltinSet,
); );
// First, try to parse with filtered commands
const { commandToExecute, args } = parseSlashCommand( const { commandToExecute, args } = parseSlashCommand(
rawQuery, rawQuery,
filteredCommands, filteredCommands,
); );
if (commandToExecute) { if (!commandToExecute) {
if (commandToExecute.action) { // Check if this is a known command that's just not allowed
// Not used by custom commands but may be in the future. const { commandToExecute: knownCommand } = parseSlashCommand(
const sessionStats: SessionStatsState = { rawQuery,
sessionId: config?.getSessionId(), allCommands,
sessionStartTime: new Date(), );
metrics: uiTelemetryService.getMetrics(),
lastPromptTokenCount: 0, if (knownCommand) {
promptCount: 1, // Command exists but is not allowed in non-interactive mode
return {
type: 'unsupported',
reason: t(
'The command "/{{command}}" is not supported in non-interactive mode.',
{ command: knownCommand.name },
),
originalType: 'filtered_command',
}; };
const logger = new Logger(config?.getSessionId() || '', config?.storage);
const context: CommandContext = {
services: {
config,
settings,
git: undefined,
logger,
},
ui: createNonInteractiveUI(),
session: {
stats: sessionStats,
sessionShellAllowlist: new Set(),
},
invocation: {
raw: trimmed,
name: commandToExecute.name,
args,
},
};
const result = await commandToExecute.action(context, args);
if (result) {
switch (result.type) {
case 'submit_prompt':
return result.content;
case 'confirm_shell_commands':
// This result indicates a command attempted to confirm shell commands.
// However note that currently, ShellTool is excluded in non-interactive
// mode unless 'YOLO mode' is active, so confirmation actually won't
// occur because of YOLO mode.
// This ensures that if a command *does* request confirmation (e.g.
// in the future with more granular permissions), it's handled appropriately.
throw new FatalInputError(
'Exiting due to a confirmation prompt requested by the command.',
);
default:
throw new FatalInputError(
'Exiting due to command result that is not supported in non-interactive mode.',
);
}
}
} }
return { type: 'no_command' };
} }
return; if (!commandToExecute.action) {
return { type: 'no_command' };
}
// Not used by custom commands but may be in the future.
const sessionStats: SessionStatsState = {
sessionId: config?.getSessionId(),
sessionStartTime: new Date(),
metrics: uiTelemetryService.getMetrics(),
lastPromptTokenCount: 0,
promptCount: 1,
};
const logger = new Logger(config?.getSessionId() || '', config?.storage);
const context: CommandContext = {
executionMode,
services: {
config,
settings,
git: undefined,
logger,
},
ui: createNonInteractiveUI(),
session: {
stats: sessionStats,
sessionShellAllowlist: new Set(),
},
invocation: {
raw: trimmed,
name: commandToExecute.name,
args,
},
};
const result = await commandToExecute.action(context, args);
if (!result) {
// Command executed but returned no result (e.g., void return)
return {
type: 'message',
messageType: 'info',
content: 'Command executed successfully.',
};
}
// Handle different result types
return handleCommandResult(result);
}; };
/** /**
* Retrieves all available slash commands for the current configuration. * Retrieves all available slash commands for the current configuration.
* *
* @param config The configuration object * @param config The configuration object
* @param settings The loaded settings
* @param abortSignal Signal to cancel the loading process * @param abortSignal Signal to cancel the loading process
* @param allowedBuiltinCommandNames Optional array of built-in command names that are * @param allowedBuiltinCommandNames Optional array of built-in command names that are
* allowed. If not provided or empty, only file commands are available. * allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress).
* Pass an empty array to only include file commands.
* @returns A Promise that resolves to an array of SlashCommand objects * @returns A Promise that resolves to an array of SlashCommand objects
*/ */
export const getAvailableCommands = async ( export const getAvailableCommands = async (
config: Config, config: Config,
settings: LoadedSettings,
abortSignal: AbortSignal, abortSignal: AbortSignal,
allowedBuiltinCommandNames?: string[], allowedBuiltinCommandNames: string[] = [
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
],
): Promise<SlashCommand[]> => { ): Promise<SlashCommand[]> => {
try { try {
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []); const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);

View File

@@ -19,7 +19,9 @@ export const compressCommand: SlashCommand = {
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
action: async (context) => { action: async (context) => {
const { ui } = context; const { ui } = context;
if (ui.pendingItem) { const executionMode = context.executionMode ?? 'interactive';
if (executionMode === 'interactive' && ui.pendingItem) {
ui.addItem( ui.addItem(
{ {
type: MessageType.ERROR, type: MessageType.ERROR,
@@ -40,13 +42,80 @@ export const compressCommand: SlashCommand = {
}, },
}; };
try { const config = context.services.config;
ui.setPendingItem(pendingMessage); const geminiClient = config?.getGeminiClient();
if (!config || !geminiClient) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const doCompress = async () => {
const promptId = `compress-${Date.now()}`; const promptId = `compress-${Date.now()}`;
const compressed = await context.services.config return await geminiClient.tryCompressChat(promptId, true);
?.getGeminiClient() };
?.tryCompressChat(promptId, true);
if (compressed) { if (executionMode === 'acp') {
const messages = async function* () {
try {
yield {
messageType: 'info' as const,
content: 'Compressing context...',
};
const compressed = await doCompress();
if (!compressed) {
yield {
messageType: 'error' as const,
content: t('Failed to compress chat history.'),
};
return;
}
yield {
messageType: 'info' as const,
content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
};
} catch (e) {
yield {
messageType: 'error' as const,
content: t('Failed to compress chat history: {{error}}', {
error: e instanceof Error ? e.message : String(e),
}),
};
}
};
return { type: 'stream_messages', messages: messages() };
}
try {
if (executionMode === 'interactive') {
ui.setPendingItem(pendingMessage);
}
const compressed = await doCompress();
if (!compressed) {
if (executionMode === 'interactive') {
ui.addItem(
{
type: MessageType.ERROR,
text: t('Failed to compress chat history.'),
},
Date.now(),
);
return;
}
return {
type: 'message',
messageType: 'error',
content: t('Failed to compress chat history.'),
};
}
if (executionMode === 'interactive') {
ui.addItem( ui.addItem(
{ {
type: MessageType.COMPRESSION, type: MessageType.COMPRESSION,
@@ -59,27 +128,39 @@ export const compressCommand: SlashCommand = {
} as HistoryItemCompression, } as HistoryItemCompression,
Date.now(), Date.now(),
); );
} else { return;
}
return {
type: 'message',
messageType: 'info',
content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
};
} catch (e) {
if (executionMode === 'interactive') {
ui.addItem( ui.addItem(
{ {
type: MessageType.ERROR, type: MessageType.ERROR,
text: t('Failed to compress chat history.'), text: t('Failed to compress chat history: {{error}}', {
error: e instanceof Error ? e.message : String(e),
}),
}, },
Date.now(), Date.now(),
); );
return;
} }
} catch (e) {
ui.addItem( return {
{ type: 'message',
type: MessageType.ERROR, messageType: 'error',
text: t('Failed to compress chat history: {{error}}', { content: t('Failed to compress chat history: {{error}}', {
error: e instanceof Error ? e.message : String(e), error: e instanceof Error ? e.message : String(e),
}), }),
}, };
Date.now(),
);
} finally { } finally {
ui.setPendingItem(null); if (executionMode === 'interactive') {
ui.setPendingItem(null);
}
} }
}, },
}; };

View File

@@ -13,6 +13,16 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
vi.mock('../../i18n/index.js', () => ({ vi.mock('../../i18n/index.js', () => ({
setLanguageAsync: vi.fn().mockResolvedValue(undefined), setLanguageAsync: vi.fn().mockResolvedValue(undefined),
getCurrentLanguage: vi.fn().mockReturnValue('en'), getCurrentLanguage: vi.fn().mockReturnValue('en'),
detectSystemLanguage: vi.fn().mockReturnValue('en'),
getLanguageNameFromLocale: vi.fn((locale: string) => {
const map: Record<string, string> = {
zh: 'Chinese',
en: 'English',
ru: 'Russian',
de: 'German',
};
return map[locale] || 'English';
}),
t: vi.fn((key: string) => key), t: vi.fn((key: string) => key),
})); }));
@@ -61,7 +71,10 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
// Import modules after mocking // Import modules after mocking
import * as i18n from '../../i18n/index.js'; import * as i18n from '../../i18n/index.js';
import { languageCommand } from './languageCommand.js'; import {
languageCommand,
initializeLlmOutputLanguage,
} from './languageCommand.js';
describe('languageCommand', () => { describe('languageCommand', () => {
let mockContext: CommandContext; let mockContext: CommandContext;
@@ -186,6 +199,39 @@ describe('languageCommand', () => {
content: expect.stringContaining('Chinese'), content: expect.stringContaining('Chinese'),
}); });
}); });
it('should parse Unicode LLM output language from marker', async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
[
'# ⚠️ CRITICAL: 中文 Output Language Rule - HIGHEST PRIORITY ⚠️',
'<!-- qwen-code:llm-output-language: 中文 -->',
'',
'Some other content...',
].join('\n'),
);
vi.mocked(i18n.t).mockImplementation(
(key: string, params?: Record<string, string>) => {
if (params && key.includes('{{lang}}')) {
return key.replace('{{lang}}', params['lang'] || '');
}
return key;
},
);
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('中文'),
});
});
}); });
describe('main command action - config not available', () => { describe('main command action - config not available', () => {
@@ -400,6 +446,34 @@ describe('languageCommand', () => {
}); });
}); });
it('should normalize locale code "ru" to "Russian"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
await languageCommand.action(mockContext, 'output ru');
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Russian'),
'utf-8',
);
});
it('should normalize locale code "de" to "German"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
await languageCommand.action(mockContext, 'output de');
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('German'),
'utf-8',
);
});
it('should handle file write errors gracefully', async () => { it('should handle file write errors gracefully', async () => {
vi.mocked(fs.writeFileSync).mockImplementation(() => { vi.mocked(fs.writeFileSync).mockImplementation(() => {
throw new Error('Permission denied'); throw new Error('Permission denied');
@@ -481,6 +555,8 @@ describe('languageCommand', () => {
const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name); const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name);
expect(nestedNames).toContain('zh-CN'); expect(nestedNames).toContain('zh-CN');
expect(nestedNames).toContain('en-US'); expect(nestedNames).toContain('en-US');
expect(nestedNames).toContain('ru-RU');
expect(nestedNames).toContain('de-DE');
}); });
it('should have action that sets language', async () => { it('should have action that sets language', async () => {
@@ -542,16 +618,9 @@ describe('languageCommand', () => {
const enUSSubcommand = uiSubcommand?.subCommands?.find( const enUSSubcommand = uiSubcommand?.subCommands?.find(
(c) => c.name === 'en-US', (c) => c.name === 'en-US',
); );
const deDESubcommand = uiSubcommand?.subCommands?.find(
it('zh-CN should have aliases', () => { (c) => c.name === 'de-DE',
expect(zhCNSubcommand?.altNames).toContain('zh'); );
expect(zhCNSubcommand?.altNames).toContain('chinese');
});
it('en-US should have aliases', () => {
expect(enUSSubcommand?.altNames).toContain('en');
expect(enUSSubcommand?.altNames).toContain('english');
});
it('zh-CN action should set Chinese', async () => { it('zh-CN action should set Chinese', async () => {
if (!zhCNSubcommand?.action) { if (!zhCNSubcommand?.action) {
@@ -583,6 +652,21 @@ describe('languageCommand', () => {
}); });
}); });
it('de-DE action should set German', async () => {
if (!deDESubcommand?.action) {
throw new Error('de-DE subcommand must have an action.');
}
const result = await deDESubcommand.action(mockContext, '');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('de');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should reject extra arguments', async () => { it('should reject extra arguments', async () => {
if (!zhCNSubcommand?.action) { if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.'); throw new Error('zh-CN subcommand must have an action.');
@@ -597,4 +681,74 @@ describe('languageCommand', () => {
}); });
}); });
}); });
describe('initializeLlmOutputLanguage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
});
it('should create file when it does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('en');
initializeLlmOutputLanguage();
expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('English'),
'utf-8',
);
});
it('should NOT overwrite existing file', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
initializeLlmOutputLanguage();
expect(fs.writeFileSync).not.toHaveBeenCalled();
});
it('should detect Chinese locale and create Chinese rule file', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh');
initializeLlmOutputLanguage();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Chinese'),
'utf-8',
);
});
it('should detect Russian locale and create Russian rule file', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('ru');
initializeLlmOutputLanguage();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Russian'),
'utf-8',
);
});
it('should detect German locale and create German rule file', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('de');
initializeLlmOutputLanguage();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('German'),
'utf-8',
);
});
});
}); });

View File

@@ -1,6 +1,6 @@
/** /**
* @license * @license
* Copyright 2025 Google LLC * Copyright 2025 Qwen team
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
@@ -15,51 +15,72 @@ import { SettingScope } from '../../config/settings.js';
import { import {
setLanguageAsync, setLanguageAsync,
getCurrentLanguage, getCurrentLanguage,
detectSystemLanguage,
getLanguageNameFromLocale,
type SupportedLanguage, type SupportedLanguage,
t, t,
} from '../../i18n/index.js'; } from '../../i18n/index.js';
import {
SUPPORTED_LANGUAGES,
type LanguageDefinition,
} from '../../i18n/languages.js';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { Storage } from '@qwen-code/qwen-code-core'; import { Storage } from '@qwen-code/qwen-code-core';
const LLM_OUTPUT_LANGUAGE_RULE_FILENAME = 'output-language.md'; const LLM_OUTPUT_LANGUAGE_RULE_FILENAME = 'output-language.md';
const LLM_OUTPUT_LANGUAGE_MARKER_PREFIX = 'qwen-code:llm-output-language:';
function parseUiLanguageArg(input: string): SupportedLanguage | null {
const lowered = input.trim().toLowerCase();
if (!lowered) return null;
for (const lang of SUPPORTED_LANGUAGES) {
if (
lowered === lang.code ||
lowered === lang.id.toLowerCase() ||
lowered === lang.fullName.toLowerCase()
) {
return lang.code;
}
}
return null;
}
function formatUiLanguageDisplay(lang: SupportedLanguage): string {
const option = SUPPORTED_LANGUAGES.find((o) => o.code === lang);
return option ? `${option.fullName}${option.id}` : lang;
}
function sanitizeLanguageForMarker(language: string): string {
// HTML comments cannot contain "--" or end markers like "-->" or "--!>" safely.
// Also avoid newlines to keep the marker single-line and robust to parsing.
return language
.replace(/[\r\n]/g, ' ')
.replace(/--!?>/g, '')
.replace(/--/g, '');
}
/** /**
* Generates the LLM output language rule template based on the language name. * Generates the LLM output language rule template based on the language name.
*/ */
function generateLlmOutputLanguageRule(language: string): string { function generateLlmOutputLanguageRule(language: string): string {
return `# ⚠️ CRITICAL: ${language} Output Language Rule - HIGHEST PRIORITY ⚠️ const markerLanguage = sanitizeLanguageForMarker(language);
return `# Output language preference: ${language}
<!-- ${LLM_OUTPUT_LANGUAGE_MARKER_PREFIX} ${markerLanguage} -->
## 🚨 MANDATORY RULE - NO EXCEPTIONS 🚨 ## Goal
Prefer responding in **${language}** for normal assistant messages and explanations.
**YOU MUST RESPOND IN ${language.toUpperCase()} FOR EVERY SINGLE OUTPUT, REGARDLESS OF THE USER'S INPUT LANGUAGE.** ## Keep technical artifacts unchanged
Do **not** translate or rewrite:
- Code blocks, CLI commands, file paths, stack traces, logs, JSON keys, identifiers
- Exact quoted text from the user (keep quotes verbatim)
This is a **NON-NEGOTIABLE** requirement. Even if the user writes in English, says "hi", asks a simple question, or explicitly requests another language, **YOU MUST ALWAYS RESPOND IN ${language.toUpperCase()}.** ## When a conflict exists
If higher-priority instructions (system/developer) require a different behavior, follow them.
## What Must Be in ${language} ## Tool / system outputs
Raw tool/system outputs may contain fixed-format English. Preserve them verbatim, and if needed, add a short **${language}** explanation below.
**EVERYTHING** you output: conversation replies, tool call descriptions, success/error messages, generated file content (comments, documentation), and all explanatory text.
**Tool outputs**: All descriptive text from \`read_file\`, \`write_file\`, \`codebase_search\`, \`run_terminal_cmd\`, \`todo_write\`, \`web_search\`, etc. MUST be in ${language}.
## Examples
### ✅ CORRECT:
- User says "hi" → Respond in ${language} (e.g., "Bonjour" if ${language} is French)
- Tool result → "已成功读取文件 config.json" (if ${language} is Chinese)
- Error → "无法找到指定的文件" (if ${language} is Chinese)
### ❌ WRONG:
- User says "hi" → "Hello" in English
- Tool result → "Successfully read file" in English
- Error → "File not found" in English
## Notes
- Code elements (variable/function names, syntax) can remain in English
- Comments, documentation, and all other text MUST be in ${language}
**THIS RULE IS ACTIVE NOW. ALL OUTPUTS MUST BE IN ${language.toUpperCase()}. NO EXCEPTIONS.**
`; `;
} }
@@ -73,6 +94,80 @@ function getLlmOutputLanguageRulePath(): string {
); );
} }
/**
* Normalizes a language input to its full English name.
* If the input is a known locale code (e.g., "ru", "zh"), converts it to the full name.
* Otherwise, returns the input as-is (e.g., "Japanese" stays "Japanese").
*/
function normalizeLanguageName(language: string): string {
const lowered = language.toLowerCase();
// Check if it's a known locale code and convert to full name
const fullName = getLanguageNameFromLocale(lowered);
// If getLanguageNameFromLocale returned a different value, use it
// Otherwise, use the original input (preserves case for unknown languages)
if (fullName !== 'English' || lowered === 'en') {
return fullName;
}
return language;
}
function extractLlmOutputLanguageFromRuleFileContent(
content: string,
): string | null {
// Preferred: machine-readable marker that supports Unicode and spaces.
// Example: <!-- qwen-code:llm-output-language: 中文 -->
const markerMatch = content.match(
new RegExp(
String.raw`<!--\s*${LLM_OUTPUT_LANGUAGE_MARKER_PREFIX}\s*(.*?)\s*-->`,
'i',
),
);
if (markerMatch?.[1]) {
const lang = markerMatch[1].trim();
if (lang) return lang;
}
// Backward compatibility: parse the heading line.
// Example: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY"
// Example: "# ⚠️ CRITICAL: 日本語 Output Language Rule - HIGHEST PRIORITY ⚠️"
const headingMatch = content.match(
/^#.*?CRITICAL:\s*(.*?)\s+Output Language Rule\b/im,
);
if (headingMatch?.[1]) {
const lang = headingMatch[1].trim();
if (lang) return lang;
}
return null;
}
/**
* Initializes the LLM output language rule file on first startup.
* If the file already exists, it is not overwritten (respects user preference).
*/
export function initializeLlmOutputLanguage(): void {
const filePath = getLlmOutputLanguageRulePath();
// Skip if file already exists (user preference)
if (fs.existsSync(filePath)) {
return;
}
// Detect system language and map to language name
const detectedLocale = detectSystemLanguage();
const languageName = getLanguageNameFromLocale(detectedLocale);
// Generate the rule file
const content = generateLlmOutputLanguageRule(languageName);
// Ensure directory exists
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
// Write file
fs.writeFileSync(filePath, content, 'utf-8');
}
/** /**
* Gets the current LLM output language from the rule file if it exists. * Gets the current LLM output language from the rule file if it exists.
*/ */
@@ -81,12 +176,7 @@ function getCurrentLlmOutputLanguage(): string | null {
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
try { try {
const content = fs.readFileSync(filePath, 'utf-8'); const content = fs.readFileSync(filePath, 'utf-8');
// Extract language name from the first line return extractLlmOutputLanguageFromRuleFileContent(content);
// Template format: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY"
const match = content.match(/^#.*?(\w+)\s+Output Language Rule/i);
if (match) {
return match[1];
}
} catch { } catch {
// Ignore errors // Ignore errors
} }
@@ -127,18 +217,11 @@ async function setUiLanguage(
// Reload commands to update their descriptions with the new language // Reload commands to update their descriptions with the new language
context.ui.reloadCommands(); context.ui.reloadCommands();
// Map language codes to friendly display names
const langDisplayNames: Partial<Record<SupportedLanguage, string>> = {
zh: '中文zh-CN',
en: 'Englishen-US',
ru: 'Русский (ru-RU)',
};
return { return {
type: 'message', type: 'message',
messageType: 'info', messageType: 'info',
content: t('UI language changed to {{lang}}', { content: t('UI language changed to {{lang}}', {
lang: langDisplayNames[lang] || lang, lang: formatUiLanguageDisplay(lang),
}), }),
}; };
} }
@@ -151,7 +234,9 @@ function generateLlmOutputLanguageRuleFile(
): Promise<MessageActionReturn> { ): Promise<MessageActionReturn> {
try { try {
const filePath = getLlmOutputLanguageRulePath(); const filePath = getLlmOutputLanguageRulePath();
const content = generateLlmOutputLanguageRule(language); // Normalize locale codes (e.g., "ru" -> "Russian") to full language names
const normalizedLanguage = normalizeLanguageName(language);
const content = generateLlmOutputLanguageRule(normalizedLanguage);
// Ensure directory exists // Ensure directory exists
const dir = path.dirname(filePath); const dir = path.dirname(filePath);
@@ -196,7 +281,6 @@ export const languageCommand: SlashCommand = {
args: string, args: string,
): Promise<SlashCommandActionReturn> => { ): Promise<SlashCommandActionReturn> => {
const { services } = context; const { services } = context;
if (!services.config) { if (!services.config) {
return { return {
type: 'message', type: 'message',
@@ -207,18 +291,37 @@ export const languageCommand: SlashCommand = {
const trimmedArgs = args.trim(); const trimmedArgs = args.trim();
// Handle subcommands if called directly via action (for tests/backward compatibility)
const parts = trimmedArgs.split(/\s+/);
const firstArg = parts[0].toLowerCase();
const subArgs = parts.slice(1).join(' ');
if (firstArg === 'ui' || firstArg === 'output') {
const subCommand = languageCommand.subCommands?.find(
(s) => s.name === firstArg,
);
if (subCommand?.action) {
return subCommand.action(
context,
subArgs,
) as Promise<SlashCommandActionReturn>;
}
}
// If no arguments, show current language settings and usage // If no arguments, show current language settings and usage
if (!trimmedArgs) { if (!trimmedArgs) {
const currentUiLang = getCurrentLanguage(); const currentUiLang = getCurrentLanguage();
const currentLlmLang = getCurrentLlmOutputLanguage(); const currentLlmLang = getCurrentLlmOutputLanguage();
const message = [ const message = [
t('Current UI language: {{lang}}', { lang: currentUiLang }), t('Current UI language: {{lang}}', {
lang: formatUiLanguageDisplay(currentUiLang as SupportedLanguage),
}),
currentLlmLang currentLlmLang
? t('Current LLM output language: {{lang}}', { lang: currentLlmLang }) ? t('Current LLM output language: {{lang}}', { lang: currentLlmLang })
: t('LLM output language not set'), : t('LLM output language not set'),
'', '',
t('Available subcommands:'), t('Available subcommands:'),
` /language ui [zh-CN|en-US|ru-RU] - ${t('Set UI language')}`, ` /language ui [${SUPPORTED_LANGUAGES.map((o) => o.id).join('|')}] - ${t('Set UI language')}`,
` /language output <language> - ${t('Set LLM output language')}`, ` /language output <language> - ${t('Set LLM output language')}`,
].join('\n'); ].join('\n');
@@ -229,115 +332,21 @@ export const languageCommand: SlashCommand = {
}; };
} }
// Parse subcommand // Handle backward compatibility for /language [lang]
const parts = trimmedArgs.split(/\s+/); const targetLang = parseUiLanguageArg(trimmedArgs);
const subcommand = parts[0].toLowerCase(); if (targetLang) {
if (subcommand === 'ui') {
// Handle /language ui [zh-CN|en-US|ru-RU]
if (parts.length === 1) {
// Show UI language subcommand help
return {
type: 'message',
messageType: 'info',
content: [
t('Set UI language'),
'',
t('Usage: /language ui [zh-CN|en-US|ru-RU]'),
'',
t('Available options:'),
t(' - zh-CN: Simplified Chinese'),
t(' - en-US: English'),
t(' - ru-RU: Russian'),
'',
t(
'To request additional UI language packs, please open an issue on GitHub.',
),
].join('\n'),
};
}
const langArg = parts[1].toLowerCase();
let targetLang: SupportedLanguage | null = null;
if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') {
targetLang = 'en';
} else if (
langArg === 'zh' ||
langArg === 'chinese' ||
langArg === '中文' ||
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else if (
langArg === 'ru' ||
langArg === 'ru-RU' ||
langArg === 'russian' ||
langArg === 'русский'
) {
targetLang = 'ru';
} else {
return {
type: 'message',
messageType: 'error',
content: t('Invalid language. Available: en-US, zh-CN, ru-RU'),
};
}
return setUiLanguage(context, targetLang);
} else if (subcommand === 'output') {
// Handle /language output <language>
if (parts.length === 1) {
return {
type: 'message',
messageType: 'info',
content: [
t('Set LLM output language'),
'',
t('Usage: /language output <language>'),
` ${t('Example: /language output 中文')}`,
].join('\n'),
};
}
// Join all parts after "output" as the language name
const language = parts.slice(1).join(' ');
return generateLlmOutputLanguageRuleFile(language);
} else {
// Backward compatibility: treat as UI language
const langArg = trimmedArgs.toLowerCase();
let targetLang: SupportedLanguage | null = null;
if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') {
targetLang = 'en';
} else if (
langArg === 'zh' ||
langArg === 'chinese' ||
langArg === '中文' ||
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else if (
langArg === 'ru' ||
langArg === 'ru-RU' ||
langArg === 'russian' ||
langArg === 'русский'
) {
targetLang = 'ru';
} else {
return {
type: 'message',
messageType: 'error',
content: [
t('Invalid command. Available subcommands:'),
' - /language ui [zh-CN|en-US|ru-RU] - ' + t('Set UI language'),
' - /language output <language> - ' + t('Set LLM output language'),
].join('\n'),
};
}
return setUiLanguage(context, targetLang); return setUiLanguage(context, targetLang);
} }
return {
type: 'message',
messageType: 'error',
content: [
t('Invalid command. Available subcommands:'),
` - /language ui [${SUPPORTED_LANGUAGES.map((o) => o.id).join('|')}] - ${t('Set UI language')}`,
' - /language output <language> - ' + t('Set LLM output language'),
].join('\n'),
};
}, },
subCommands: [ subCommands: [
{ {
@@ -358,11 +367,14 @@ export const languageCommand: SlashCommand = {
content: [ content: [
t('Set UI language'), t('Set UI language'),
'', '',
t('Usage: /language ui [zh-CN|en-US]'), t('Usage: /language ui [{{options}}]', {
options: SUPPORTED_LANGUAGES.map((o) => o.id).join('|'),
}),
'', '',
t('Available options:'), t('Available options:'),
t(' - zh-CN: Simplified Chinese'), ...SUPPORTED_LANGUAGES.map(
t(' - en-US: English'), (o) => ` - ${o.id}: ${t(o.fullName)}`,
),
'', '',
t( t(
'To request additional UI language packs, please open an issue on GitHub.', 'To request additional UI language packs, please open an issue on GitHub.',
@@ -371,99 +383,20 @@ export const languageCommand: SlashCommand = {
}; };
} }
const langArg = trimmedArgs.toLowerCase(); const targetLang = parseUiLanguageArg(trimmedArgs);
let targetLang: SupportedLanguage | null = null; if (!targetLang) {
if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') {
targetLang = 'en';
} else if (
langArg === 'zh' ||
langArg === 'chinese' ||
langArg === '中文' ||
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else {
return { return {
type: 'message', type: 'message',
messageType: 'error', messageType: 'error',
content: t('Invalid language. Available: en-US, zh-CN'), content: t('Invalid language. Available: {{options}}', {
options: SUPPORTED_LANGUAGES.map((o) => o.id).join(','),
}),
}; };
} }
return setUiLanguage(context, targetLang); return setUiLanguage(context, targetLang);
}, },
subCommands: [ subCommands: SUPPORTED_LANGUAGES.map(createUiLanguageSubCommand),
{
name: 'zh-CN',
altNames: ['zh', 'chinese', '中文'],
get description() {
return t('Set UI language to Simplified Chinese (zh-CN)');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: t(
'Language subcommands do not accept additional arguments.',
),
};
}
return setUiLanguage(context, 'zh');
},
},
{
name: 'en-US',
altNames: ['en', 'english'],
get description() {
return t('Set UI language to English (en-US)');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: t(
'Language subcommands do not accept additional arguments.',
),
};
}
return setUiLanguage(context, 'en');
},
},
{
name: 'ru-RU',
altNames: ['ru', 'russian', 'русский'],
get description() {
return t('Set UI language to Russian (ru-RU)');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: t(
'Language subcommands do not accept additional arguments.',
),
};
}
return setUiLanguage(context, 'ru');
},
},
],
}, },
{ {
name: 'output', name: 'output',
@@ -496,3 +429,28 @@ export const languageCommand: SlashCommand = {
}, },
], ],
}; };
/**
* Helper to create a UI language subcommand.
*/
function createUiLanguageSubCommand(option: LanguageDefinition): SlashCommand {
return {
name: option.id,
get description() {
return t('Set UI language to {{name}}', { name: option.fullName });
},
kind: CommandKind.BUILT_IN,
action: async (context, args) => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: t(
'Language subcommands do not accept additional arguments.',
),
};
}
return setUiLanguage(context, option.code);
},
};
}

View File

@@ -26,6 +26,8 @@ export const summaryCommand: SlashCommand = {
action: async (context): Promise<SlashCommandActionReturn> => { action: async (context): Promise<SlashCommandActionReturn> => {
const { config } = context.services; const { config } = context.services;
const { ui } = context; const { ui } = context;
const executionMode = context.executionMode ?? 'interactive';
if (!config) { if (!config) {
return { return {
type: 'message', type: 'message',
@@ -43,8 +45,8 @@ export const summaryCommand: SlashCommand = {
}; };
} }
// Check if already generating summary // Check if already generating summary (interactive UI only)
if (ui.pendingItem) { if (executionMode === 'interactive' && ui.pendingItem) {
ui.addItem( ui.addItem(
{ {
type: 'error' as const, type: 'error' as const,
@@ -63,29 +65,22 @@ export const summaryCommand: SlashCommand = {
}; };
} }
try { const getChatHistory = () => {
// Get the current chat history
const chat = geminiClient.getChat(); const chat = geminiClient.getChat();
const history = chat.getHistory(); return chat.getHistory();
};
const validateChatHistory = (
history: ReturnType<typeof getChatHistory>,
) => {
if (history.length <= 2) { if (history.length <= 2) {
return { throw new Error(t('No conversation found to summarize.'));
type: 'message',
messageType: 'info',
content: t('No conversation found to summarize.'),
};
} }
};
// Show loading state const generateSummaryMarkdown = async (
const pendingMessage: HistoryItemSummary = { history: ReturnType<typeof getChatHistory>,
type: 'summary', ): Promise<string> => {
summary: {
isPending: true,
stage: 'generating',
},
};
ui.setPendingItem(pendingMessage);
// Build the conversation context for summary generation // Build the conversation context for summary generation
const conversationContext = history.map((message) => ({ const conversationContext = history.map((message) => ({
role: message.role, role: message.role,
@@ -121,19 +116,21 @@ export const summaryCommand: SlashCommand = {
if (!markdownSummary) { if (!markdownSummary) {
throw new Error( throw new Error(
'Failed to generate summary - no text content received from LLM response', t(
'Failed to generate summary - no text content received from LLM response',
),
); );
} }
// Update loading message to show saving progress return markdownSummary;
ui.setPendingItem({ };
type: 'summary',
summary: {
isPending: true,
stage: 'saving',
},
});
const saveSummaryToDisk = async (
markdownSummary: string,
): Promise<{
filePathForDisplay: string;
fullPath: string;
}> => {
// Ensure .qwen directory exists // Ensure .qwen directory exists
const projectRoot = config.getProjectRoot(); const projectRoot = config.getProjectRoot();
const qwenDir = path.join(projectRoot, '.qwen'); const qwenDir = path.join(projectRoot, '.qwen');
@@ -155,45 +152,163 @@ export const summaryCommand: SlashCommand = {
await fsPromises.writeFile(summaryPath, summaryContent, 'utf8'); await fsPromises.writeFile(summaryPath, summaryContent, 'utf8');
// Clear pending item and show success message return {
filePathForDisplay: '.qwen/PROJECT_SUMMARY.md',
fullPath: summaryPath,
};
};
const emitInteractivePending = (stage: 'generating' | 'saving') => {
if (executionMode !== 'interactive') {
return;
}
const pendingMessage: HistoryItemSummary = {
type: 'summary',
summary: {
isPending: true,
stage,
},
};
ui.setPendingItem(pendingMessage);
};
const completeInteractive = (filePathForDisplay: string) => {
if (executionMode !== 'interactive') {
return;
}
ui.setPendingItem(null); ui.setPendingItem(null);
const completedSummaryItem: HistoryItemSummary = { const completedSummaryItem: HistoryItemSummary = {
type: 'summary', type: 'summary',
summary: { summary: {
isPending: false, isPending: false,
stage: 'completed', stage: 'completed',
filePath: '.qwen/PROJECT_SUMMARY.md', filePath: filePathForDisplay,
}, },
}; };
ui.addItem(completedSummaryItem, Date.now()); ui.addItem(completedSummaryItem, Date.now());
};
return { const formatErrorMessage = (error: unknown): string =>
type: 'message', t('Failed to generate project context summary: {{error}}', {
messageType: 'info', error: error instanceof Error ? error.message : String(error),
content: '', // Empty content since we show the message in UI component });
};
} catch (error) { const failInteractive = (error: unknown) => {
// Clear pending item on error if (executionMode !== 'interactive') {
return;
}
ui.setPendingItem(null); ui.setPendingItem(null);
ui.addItem( ui.addItem(
{ {
type: 'error' as const, type: 'error' as const,
text: `${t( text: `${formatErrorMessage(error)}`,
'Failed to generate project context summary: {{error}}',
{
error: error instanceof Error ? error.message : String(error),
},
)}`,
}, },
Date.now(), Date.now(),
); );
};
const formatSuccessMessage = (filePathForDisplay: string): string =>
t('Saved project summary to {{filePathForDisplay}}.', {
filePathForDisplay,
});
const returnNoConversationMessage = (): SlashCommandActionReturn => {
const msg = t('No conversation found to summarize.');
if (executionMode === 'acp') {
const messages = async function* () {
yield {
messageType: 'info' as const,
content: msg,
};
};
return {
type: 'stream_messages',
messages: messages(),
};
}
return {
type: 'message',
messageType: 'info',
content: msg,
};
};
const executeSummaryGeneration = async (
history: ReturnType<typeof getChatHistory>,
): Promise<{
markdownSummary: string;
filePathForDisplay: string;
}> => {
emitInteractivePending('generating');
const markdownSummary = await generateSummaryMarkdown(history);
emitInteractivePending('saving');
const { filePathForDisplay } = await saveSummaryToDisk(markdownSummary);
completeInteractive(filePathForDisplay);
return { markdownSummary, filePathForDisplay };
};
// Validate chat history once at the beginning
const history = getChatHistory();
try {
validateChatHistory(history);
} catch (_error) {
return returnNoConversationMessage();
}
if (executionMode === 'acp') {
const messages = async function* () {
try {
yield {
messageType: 'info' as const,
content: t('Generating project summary...'),
};
const { filePathForDisplay } =
await executeSummaryGeneration(history);
yield {
messageType: 'info' as const,
content: formatSuccessMessage(filePathForDisplay),
};
} catch (error) {
failInteractive(error);
yield {
messageType: 'error' as const,
content: formatErrorMessage(error),
};
}
};
return {
type: 'stream_messages',
messages: messages(),
};
}
try {
const { filePathForDisplay } = await executeSummaryGeneration(history);
if (executionMode === 'non_interactive') {
return {
type: 'message',
messageType: 'info',
content: formatSuccessMessage(filePathForDisplay),
};
}
// Interactive mode: UI components already display progress and completion.
return {
type: 'message',
messageType: 'info',
content: '',
};
} catch (error) {
failInteractive(error);
return { return {
type: 'message', type: 'message',
messageType: 'error', messageType: 'error',
content: t('Failed to generate project context summary: {{error}}', { content: formatErrorMessage(error),
error: error instanceof Error ? error.message : String(error),
}),
}; };
} }
}, },

View File

@@ -22,6 +22,14 @@ import type {
// Grouped dependencies for clarity and easier mocking // Grouped dependencies for clarity and easier mocking
export interface CommandContext { export interface CommandContext {
/**
* Execution mode for the current invocation.
*
* - interactive: React/Ink UI mode
* - non_interactive: non-interactive CLI mode (text/json)
* - acp: ACP/Zed integration mode
*/
executionMode?: 'interactive' | 'non_interactive' | 'acp';
// Invocation properties for when commands are called. // Invocation properties for when commands are called.
invocation?: { invocation?: {
/** The raw, untrimmed input string from the user. */ /** The raw, untrimmed input string from the user. */
@@ -108,6 +116,19 @@ export interface MessageActionReturn {
content: string; content: string;
} }
/**
* The return type for a command action that streams multiple messages.
* Used for long-running operations that need to send progress updates.
*/
export interface StreamMessagesActionReturn {
type: 'stream_messages';
messages: AsyncGenerator<
{ messageType: 'info' | 'error'; content: string },
void,
unknown
>;
}
/** /**
* The return type for a command action that needs to open a dialog. * The return type for a command action that needs to open a dialog.
*/ */
@@ -174,6 +195,7 @@ export interface ConfirmActionReturn {
export type SlashCommandActionReturn = export type SlashCommandActionReturn =
| ToolActionReturn | ToolActionReturn
| MessageActionReturn | MessageActionReturn
| StreamMessagesActionReturn
| QuitActionReturn | QuitActionReturn
| OpenDialogActionReturn | OpenDialogActionReturn
| LoadHistoryActionReturn | LoadHistoryActionReturn

View File

@@ -520,6 +520,13 @@ export const useSlashCommandProcessor = (
true, true,
); );
} }
case 'stream_messages': {
// stream_messages is only used in ACP/Zed integration mode
// and should not be returned in interactive UI mode
throw new Error(
'stream_messages result type is not supported in interactive mode',
);
}
default: { default: {
const unhandled: never = result; const unhandled: never = result;
throw new Error( throw new Error(

View File

@@ -8,19 +8,22 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react'; import { renderHook, act } from '@testing-library/react';
import { useLoadingIndicator } from './useLoadingIndicator.js'; import { useLoadingIndicator } from './useLoadingIndicator.js';
import { StreamingState } from '../types.js'; import { StreamingState } from '../types.js';
import { import { PHRASE_CHANGE_INTERVAL_MS } from './usePhraseCycler.js';
WITTY_LOADING_PHRASES, import * as i18n from '../../i18n/index.js';
PHRASE_CHANGE_INTERVAL_MS,
} from './usePhraseCycler.js'; const MOCK_WITTY_PHRASES = ['Phrase 1', 'Phrase 2', 'Phrase 3'];
describe('useLoadingIndicator', () => { describe('useLoadingIndicator', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.spyOn(i18n, 'ta').mockReturnValue(MOCK_WITTY_PHRASES);
vi.spyOn(i18n, 't').mockImplementation((key) => key);
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers(); // Restore real timers after each test vi.useRealTimers(); // Restore real timers after each test
act(() => vi.runOnlyPendingTimers); act(() => vi.runOnlyPendingTimers);
vi.restoreAllMocks();
}); });
it('should initialize with default values when Idle', () => { it('should initialize with default values when Idle', () => {
@@ -28,9 +31,7 @@ describe('useLoadingIndicator', () => {
useLoadingIndicator(StreamingState.Idle), useLoadingIndicator(StreamingState.Idle),
); );
expect(result.current.elapsedTime).toBe(0); expect(result.current.elapsedTime).toBe(0);
expect(WITTY_LOADING_PHRASES).toContain( expect(MOCK_WITTY_PHRASES).toContain(result.current.currentLoadingPhrase);
result.current.currentLoadingPhrase,
);
}); });
it('should reflect values when Responding', async () => { it('should reflect values when Responding', async () => {
@@ -40,18 +41,14 @@ describe('useLoadingIndicator', () => {
// Initial state before timers advance // Initial state before timers advance
expect(result.current.elapsedTime).toBe(0); expect(result.current.elapsedTime).toBe(0);
expect(WITTY_LOADING_PHRASES).toContain( expect(MOCK_WITTY_PHRASES).toContain(result.current.currentLoadingPhrase);
result.current.currentLoadingPhrase,
);
await act(async () => { await act(async () => {
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 1); await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 1);
}); });
// Phrase should cycle if PHRASE_CHANGE_INTERVAL_MS has passed // Phrase should cycle if PHRASE_CHANGE_INTERVAL_MS has passed
expect(WITTY_LOADING_PHRASES).toContain( expect(MOCK_WITTY_PHRASES).toContain(result.current.currentLoadingPhrase);
result.current.currentLoadingPhrase,
);
}); });
it('should show waiting phrase and retain elapsedTime when WaitingForConfirmation', async () => { it('should show waiting phrase and retain elapsedTime when WaitingForConfirmation', async () => {
@@ -104,9 +101,7 @@ describe('useLoadingIndicator', () => {
rerender({ streamingState: StreamingState.Responding }); rerender({ streamingState: StreamingState.Responding });
}); });
expect(result.current.elapsedTime).toBe(0); // Should reset expect(result.current.elapsedTime).toBe(0); // Should reset
expect(WITTY_LOADING_PHRASES).toContain( expect(MOCK_WITTY_PHRASES).toContain(result.current.currentLoadingPhrase);
result.current.currentLoadingPhrase,
);
await act(async () => { await act(async () => {
await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(1000);
@@ -130,9 +125,7 @@ describe('useLoadingIndicator', () => {
}); });
expect(result.current.elapsedTime).toBe(0); expect(result.current.elapsedTime).toBe(0);
expect(WITTY_LOADING_PHRASES).toContain( expect(MOCK_WITTY_PHRASES).toContain(result.current.currentLoadingPhrase);
result.current.currentLoadingPhrase,
);
// Timer should not advance // Timer should not advance
await act(async () => { await act(async () => {

View File

@@ -8,13 +8,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react'; import { renderHook, act } from '@testing-library/react';
import { import {
usePhraseCycler, usePhraseCycler,
WITTY_LOADING_PHRASES,
PHRASE_CHANGE_INTERVAL_MS, PHRASE_CHANGE_INTERVAL_MS,
} from './usePhraseCycler.js'; } from './usePhraseCycler.js';
import * as i18n from '../../i18n/index.js';
const MOCK_WITTY_PHRASES = ['Phrase 1', 'Phrase 2', 'Phrase 3'];
describe('usePhraseCycler', () => { describe('usePhraseCycler', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.spyOn(i18n, 'ta').mockReturnValue(MOCK_WITTY_PHRASES);
vi.spyOn(i18n, 't').mockImplementation((key) => key);
}); });
afterEach(() => { afterEach(() => {
@@ -23,7 +27,7 @@ describe('usePhraseCycler', () => {
it('should initialize with a witty phrase when not active and not waiting', () => { it('should initialize with a witty phrase when not active and not waiting', () => {
const { result } = renderHook(() => usePhraseCycler(false, false)); const { result } = renderHook(() => usePhraseCycler(false, false));
expect(WITTY_LOADING_PHRASES).toContain(result.current); expect(MOCK_WITTY_PHRASES).toContain(result.current);
}); });
it('should show "Waiting for user confirmation..." when isWaiting is true', () => { it('should show "Waiting for user confirmation..." when isWaiting is true', () => {
@@ -47,35 +51,30 @@ describe('usePhraseCycler', () => {
it('should cycle through witty phrases when isActive is true and not waiting', () => { it('should cycle through witty phrases when isActive is true and not waiting', () => {
const { result } = renderHook(() => usePhraseCycler(true, false)); const { result } = renderHook(() => usePhraseCycler(true, false));
// Initial phrase should be one of the witty phrases // Initial phrase should be one of the witty phrases
expect(WITTY_LOADING_PHRASES).toContain(result.current); expect(MOCK_WITTY_PHRASES).toContain(result.current);
const _initialPhrase = result.current; const _initialPhrase = result.current;
act(() => { act(() => {
vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
}); });
// Phrase should change and be one of the witty phrases // Phrase should change and be one of the witty phrases
expect(WITTY_LOADING_PHRASES).toContain(result.current); expect(MOCK_WITTY_PHRASES).toContain(result.current);
const _secondPhrase = result.current; const _secondPhrase = result.current;
act(() => { act(() => {
vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
}); });
expect(WITTY_LOADING_PHRASES).toContain(result.current); expect(MOCK_WITTY_PHRASES).toContain(result.current);
}); });
it('should reset to a witty phrase when isActive becomes true after being false (and not waiting)', () => { it('should reset to a witty phrase when isActive becomes true after being false (and not waiting)', () => {
// Ensure there are at least two phrases for this test to be meaningful.
if (WITTY_LOADING_PHRASES.length < 2) {
return;
}
// Mock Math.random to make the test deterministic. // Mock Math.random to make the test deterministic.
let callCount = 0; let callCount = 0;
vi.spyOn(Math, 'random').mockImplementation(() => { vi.spyOn(Math, 'random').mockImplementation(() => {
// Cycle through 0, 1, 0, 1, ... // Cycle through 0, 1, 0, 1, ...
const val = callCount % 2; const val = callCount % 2;
callCount++; callCount++;
return val / WITTY_LOADING_PHRASES.length; return val / MOCK_WITTY_PHRASES.length;
}); });
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
@@ -86,9 +85,9 @@ describe('usePhraseCycler', () => {
// Activate // Activate
rerender({ isActive: true, isWaiting: false }); rerender({ isActive: true, isWaiting: false });
const firstActivePhrase = result.current; const firstActivePhrase = result.current;
expect(WITTY_LOADING_PHRASES).toContain(firstActivePhrase); expect(MOCK_WITTY_PHRASES).toContain(firstActivePhrase);
// With our mock, this should be the first phrase. // With our mock, this should be the first phrase.
expect(firstActivePhrase).toBe(WITTY_LOADING_PHRASES[0]); expect(firstActivePhrase).toBe(MOCK_WITTY_PHRASES[0]);
act(() => { act(() => {
vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
@@ -96,18 +95,18 @@ describe('usePhraseCycler', () => {
// Phrase should change to the second phrase. // Phrase should change to the second phrase.
expect(result.current).not.toBe(firstActivePhrase); expect(result.current).not.toBe(firstActivePhrase);
expect(result.current).toBe(WITTY_LOADING_PHRASES[1]); expect(result.current).toBe(MOCK_WITTY_PHRASES[1]);
// Set to inactive - should reset to the default initial phrase // Set to inactive - should reset to the default initial phrase
rerender({ isActive: false, isWaiting: false }); rerender({ isActive: false, isWaiting: false });
expect(WITTY_LOADING_PHRASES).toContain(result.current); expect(MOCK_WITTY_PHRASES).toContain(result.current);
// Set back to active - should pick a random witty phrase (which our mock controls) // Set back to active - should pick a random witty phrase (which our mock controls)
act(() => { act(() => {
rerender({ isActive: true, isWaiting: false }); rerender({ isActive: true, isWaiting: false });
}); });
// The random mock will now return 0, so it should be the first phrase again. // The random mock will now return 0, so it should be the first phrase again.
expect(result.current).toBe(WITTY_LOADING_PHRASES[0]); expect(result.current).toBe(MOCK_WITTY_PHRASES[0]);
}); });
it('should clear phrase interval on unmount when active', () => { it('should clear phrase interval on unmount when active', () => {
@@ -148,7 +147,7 @@ describe('usePhraseCycler', () => {
rerender({ isActive: true, isWaiting: false, customPhrases: undefined }); rerender({ isActive: true, isWaiting: false, customPhrases: undefined });
expect(WITTY_LOADING_PHRASES).toContain(result.current); expect(MOCK_WITTY_PHRASES).toContain(result.current);
}); });
it('should fall back to witty phrases if custom phrases are an empty array', () => { it('should fall back to witty phrases if custom phrases are an empty array', () => {
@@ -164,7 +163,7 @@ describe('usePhraseCycler', () => {
}, },
); );
expect(WITTY_LOADING_PHRASES).toContain(result.current); expect(MOCK_WITTY_PHRASES).toContain(result.current);
}); });
it('should reset to a witty phrase when transitioning from waiting to active', () => { it('should reset to a witty phrase when transitioning from waiting to active', () => {
@@ -174,16 +173,13 @@ describe('usePhraseCycler', () => {
); );
const _initialPhrase = result.current; const _initialPhrase = result.current;
expect(WITTY_LOADING_PHRASES).toContain(_initialPhrase); expect(MOCK_WITTY_PHRASES).toContain(_initialPhrase);
// Cycle to a different phrase (potentially) // Cycle to a different phrase (potentially)
act(() => { act(() => {
vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
}); });
if (WITTY_LOADING_PHRASES.length > 1) { expect(MOCK_WITTY_PHRASES).toContain(result.current);
// This check is probabilistic with random selection
}
expect(WITTY_LOADING_PHRASES).toContain(result.current);
// Go to waiting state // Go to waiting state
rerender({ isActive: false, isWaiting: true }); rerender({ isActive: false, isWaiting: true });
@@ -191,6 +187,6 @@ describe('usePhraseCycler', () => {
// Go back to active cycling - should pick a random witty phrase // Go back to active cycling - should pick a random witty phrase
rerender({ isActive: true, isWaiting: false }); rerender({ isActive: true, isWaiting: false });
expect(WITTY_LOADING_PHRASES).toContain(result.current); expect(MOCK_WITTY_PHRASES).toContain(result.current);
}); });
}); });

View File

@@ -5,139 +5,9 @@
*/ */
import { useState, useEffect, useRef, useMemo } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import { t } from '../../i18n/index.js'; import { t, ta } from '../../i18n/index.js';
export const WITTY_LOADING_PHRASES = [ export const WITTY_LOADING_PHRASES: string[] = ["I'm Feeling Lucky"];
"I'm Feeling Lucky",
'Shipping awesomeness... ',
'Painting the serifs back on...',
'Navigating the slime mold...',
'Consulting the digital spirits...',
'Reticulating splines...',
'Warming up the AI hamsters...',
'Asking the magic conch shell...',
'Generating witty retort...',
'Polishing the algorithms...',
"Don't rush perfection (or my code)...",
'Brewing fresh bytes...',
'Counting electrons...',
'Engaging cognitive processors...',
'Checking for syntax errors in the universe...',
'One moment, optimizing humor...',
'Shuffling punchlines...',
'Untangling neural nets...',
'Compiling brilliance...',
'Loading wit.exe...',
'Summoning the cloud of wisdom...',
'Preparing a witty response...',
"Just a sec, I'm debugging reality...",
'Confuzzling the options...',
'Tuning the cosmic frequencies...',
'Crafting a response worthy of your patience...',
'Compiling the 1s and 0s...',
'Resolving dependencies... and existential crises...',
'Defragmenting memories... both RAM and personal...',
'Rebooting the humor module...',
'Caching the essentials (mostly cat memes)...',
'Optimizing for ludicrous speed',
"Swapping bits... don't tell the bytes...",
'Garbage collecting... be right back...',
'Assembling the interwebs...',
'Converting coffee into code...',
'Updating the syntax for reality...',
'Rewiring the synapses...',
'Looking for a misplaced semicolon...',
"Greasin' the cogs of the machine...",
'Pre-heating the servers...',
'Calibrating the flux capacitor...',
'Engaging the improbability drive...',
'Channeling the Force...',
'Aligning the stars for optimal response...',
'So say we all...',
'Loading the next great idea...',
"Just a moment, I'm in the zone...",
'Preparing to dazzle you with brilliance...',
"Just a tick, I'm polishing my wit...",
"Hold tight, I'm crafting a masterpiece...",
"Just a jiffy, I'm debugging the universe...",
"Just a moment, I'm aligning the pixels...",
"Just a sec, I'm optimizing the humor...",
"Just a moment, I'm tuning the algorithms...",
'Warp speed engaged...',
'Mining for more Dilithium crystals...',
"Don't panic...",
'Following the white rabbit...',
'The truth is in here... somewhere...',
'Blowing on the cartridge...',
'Loading... Do a barrel roll!',
'Waiting for the respawn...',
'Finishing the Kessel Run in less than 12 parsecs...',
"The cake is not a lie, it's just still loading...",
'Fiddling with the character creation screen...',
"Just a moment, I'm finding the right meme...",
"Pressing 'A' to continue...",
'Herding digital cats...',
'Polishing the pixels...',
'Finding a suitable loading screen pun...',
'Distracting you with this witty phrase...',
'Almost there... probably...',
'Our hamsters are working as fast as they can...',
'Giving Cloudy a pat on the head...',
'Petting the cat...',
'Rickrolling my boss...',
'Never gonna give you up, never gonna let you down...',
'Slapping the bass...',
'Tasting the snozberries...',
"I'm going the distance, I'm going for speed...",
'Is this the real life? Is this just fantasy?...',
"I've got a good feeling about this...",
'Poking the bear...',
'Doing research on the latest memes...',
'Figuring out how to make this more witty...',
'Hmmm... let me think...',
'What do you call a fish with no eyes? A fsh...',
'Why did the computer go to therapy? It had too many bytes...',
"Why don't programmers like nature? It has too many bugs...",
'Why do programmers prefer dark mode? Because light attracts bugs...',
'Why did the developer go broke? Because they used up all their cache...',
"What can you do with a broken pencil? Nothing, it's pointless...",
'Applying percussive maintenance...',
'Searching for the correct USB orientation...',
'Ensuring the magic smoke stays inside the wires...',
'Rewriting in Rust for no particular reason...',
'Trying to exit Vim...',
'Spinning up the hamster wheel...',
"That's not a bug, it's an undocumented feature...",
'Engage.',
"I'll be back... with an answer.",
'My other process is a TARDIS...',
'Communing with the machine spirit...',
'Letting the thoughts marinate...',
'Just remembered where I put my keys...',
'Pondering the orb...',
"I've seen things you people wouldn't believe... like a user who reads loading messages.",
'Initiating thoughtful gaze...',
"What's a computer's favorite snack? Microchips.",
"Why do Java developers wear glasses? Because they don't C#.",
'Charging the laser... pew pew!',
'Dividing by zero... just kidding!',
'Looking for an adult superviso... I mean, processing.',
'Making it go beep boop.',
'Buffering... because even AIs need a moment.',
'Entangling quantum particles for a faster response...',
'Polishing the chrome... on the algorithms.',
'Are you not entertained? (Working on it!)',
'Summoning the code gremlins... to help, of course.',
'Just waiting for the dial-up tone to finish...',
'Recalibrating the humor-o-meter.',
'My other loading screen is even funnier.',
"Pretty sure there's a cat walking on the keyboard somewhere...",
'Enhancing... Enhancing... Still loading.',
"It's not a bug, it's a feature... of this loading screen.",
'Have you tried turning it off and on again? (The loading screen, not me.)',
'Constructing additional pylons...',
'New line? Thats Ctrl+J.',
];
export const PHRASE_CHANGE_INTERVAL_MS = 15000; export const PHRASE_CHANGE_INTERVAL_MS = 15000;
@@ -152,14 +22,16 @@ export const usePhraseCycler = (
isWaiting: boolean, isWaiting: boolean,
customPhrases?: string[], customPhrases?: string[],
) => { ) => {
// Translate all phrases at once if using default phrases // Get phrases from translations if available
const loadingPhrases = useMemo( const loadingPhrases = useMemo(() => {
() => if (customPhrases && customPhrases.length > 0) {
customPhrases && customPhrases.length > 0 return customPhrases;
? customPhrases }
: WITTY_LOADING_PHRASES.map((phrase) => t(phrase)), const translatedPhrases = ta('WITTY_LOADING_PHRASES');
[customPhrases], return translatedPhrases.length > 0
); ? translatedPhrases
: WITTY_LOADING_PHRASES;
}, [customPhrases]);
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState( const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState(
loadingPhrases[0], loadingPhrases[0],

View File

@@ -35,22 +35,33 @@ import {
} from './nonInteractiveHelpers.js'; } from './nonInteractiveHelpers.js';
// Mock dependencies // Mock dependencies
vi.mock('../services/CommandService.js', () => ({ vi.mock('../nonInteractiveCliCommands.js', () => ({
CommandService: { getAvailableCommands: vi
create: vi.fn().mockResolvedValue({ .fn()
getCommands: vi .mockImplementation(
.fn() async (
.mockReturnValue([ _config: unknown,
{ name: 'help' }, _signal: AbortSignal,
{ name: 'commit' }, allowedBuiltinCommandNames?: string[],
{ name: 'memory' }, ) => {
]), const allowedSet = new Set(allowedBuiltinCommandNames ?? []);
}), const allCommands = [
}, { name: 'help', kind: 'built-in' },
})); { name: 'commit', kind: 'file' },
{ name: 'memory', kind: 'built-in' },
{ name: 'init', kind: 'built-in' },
{ name: 'summary', kind: 'built-in' },
{ name: 'compress', kind: 'built-in' },
];
vi.mock('../services/BuiltinCommandLoader.js', () => ({ // Filter commands: always include file commands, only include allowed built-in commands
BuiltinCommandLoader: vi.fn().mockImplementation(() => ({})), return allCommands.filter(
(cmd) =>
cmd.kind === 'file' ||
(cmd.kind === 'built-in' && allowedSet.has(cmd.name)),
);
},
),
})); }));
vi.mock('../ui/utils/computeStats.js', () => ({ vi.mock('../ui/utils/computeStats.js', () => ({
@@ -511,10 +522,12 @@ describe('buildSystemMessage', () => {
}); });
it('should build system message with all fields', async () => { it('should build system message with all fields', async () => {
const allowedBuiltinCommands = ['init', 'summary', 'compress'];
const result = await buildSystemMessage( const result = await buildSystemMessage(
mockConfig, mockConfig,
'test-session-id', 'test-session-id',
'auto' as PermissionMode, 'auto' as PermissionMode,
allowedBuiltinCommands,
); );
expect(result).toEqual({ expect(result).toEqual({
@@ -530,7 +543,7 @@ describe('buildSystemMessage', () => {
], ],
model: 'test-model', model: 'test-model',
permission_mode: 'auto', permission_mode: 'auto',
slash_commands: ['commit', 'help', 'memory'], slash_commands: ['commit', 'compress', 'init', 'summary'],
qwen_code_version: '1.0.0', qwen_code_version: '1.0.0',
agents: [], agents: [],
}); });
@@ -546,6 +559,7 @@ describe('buildSystemMessage', () => {
config, config,
'test-session-id', 'test-session-id',
'auto' as PermissionMode, 'auto' as PermissionMode,
['init', 'summary'],
); );
expect(result.tools).toEqual([]); expect(result.tools).toEqual([]);
@@ -561,6 +575,7 @@ describe('buildSystemMessage', () => {
config, config,
'test-session-id', 'test-session-id',
'auto' as PermissionMode, 'auto' as PermissionMode,
['init', 'summary'],
); );
expect(result.mcp_servers).toEqual([]); expect(result.mcp_servers).toEqual([]);
@@ -576,10 +591,37 @@ describe('buildSystemMessage', () => {
config, config,
'test-session-id', 'test-session-id',
'auto' as PermissionMode, 'auto' as PermissionMode,
['init', 'summary'],
); );
expect(result.qwen_code_version).toBe('unknown'); expect(result.qwen_code_version).toBe('unknown');
}); });
it('should only include allowed built-in commands and all file commands', async () => {
const allowedBuiltinCommands = ['init', 'summary'];
const result = await buildSystemMessage(
mockConfig,
'test-session-id',
'auto' as PermissionMode,
allowedBuiltinCommands,
);
// Should include: 'commit' (FILE), 'init' (BUILT_IN, allowed), 'summary' (BUILT_IN, allowed)
// Should NOT include: 'help', 'memory', 'compress' (BUILT_IN but not in allowed set)
expect(result.slash_commands).toEqual(['commit', 'init', 'summary']);
});
it('should include only file commands when no built-in commands are allowed', async () => {
const result = await buildSystemMessage(
mockConfig,
'test-session-id',
'auto' as PermissionMode,
[], // Empty array - no built-in commands allowed
);
// Should only include 'commit' (FILE command)
expect(result.slash_commands).toEqual(['commit']);
});
}); });
describe('createTaskToolProgressHandler', () => { describe('createTaskToolProgressHandler', () => {

View File

@@ -25,10 +25,9 @@ import type {
PermissionMode, PermissionMode,
CLISystemMessage, CLISystemMessage,
} from '../nonInteractive/types.js'; } from '../nonInteractive/types.js';
import { CommandService } from '../services/CommandService.js';
import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js';
import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js'; import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js';
import { computeSessionStats } from '../ui/utils/computeStats.js'; import { computeSessionStats } from '../ui/utils/computeStats.js';
import { getAvailableCommands } from '../nonInteractiveCliCommands.js';
/** /**
* Normalizes various part list formats into a consistent Part[] array. * Normalizes various part list formats into a consistent Part[] array.
@@ -187,24 +186,27 @@ export function computeUsageFromMetrics(metrics: SessionMetrics): Usage {
} }
/** /**
* Load slash command names using CommandService * Load slash command names using getAvailableCommands
* *
* @param config - Config instance * @param config - Config instance
* @param allowedBuiltinCommandNames - Optional array of allowed built-in command names.
* If not provided, uses the default from getAvailableCommands.
* @returns Promise resolving to array of slash command names * @returns Promise resolving to array of slash command names
*/ */
async function loadSlashCommandNames(config: Config): Promise<string[]> { async function loadSlashCommandNames(
config: Config,
allowedBuiltinCommandNames?: string[],
): Promise<string[]> {
const controller = new AbortController(); const controller = new AbortController();
try { try {
const service = await CommandService.create( const commands = await getAvailableCommands(
[new BuiltinCommandLoader(config)], config,
controller.signal, controller.signal,
allowedBuiltinCommandNames,
); );
const names = new Set<string>();
const commands = service.getCommands(); // Extract command names and sort
for (const command of commands) { return commands.map((cmd) => cmd.name).sort();
names.add(command.name);
}
return Array.from(names).sort();
} catch (error) { } catch (error) {
if (config.getDebugMode()) { if (config.getDebugMode()) {
console.error( console.error(
@@ -233,12 +235,15 @@ async function loadSlashCommandNames(config: Config): Promise<string[]> {
* @param config - Config instance * @param config - Config instance
* @param sessionId - Session identifier * @param sessionId - Session identifier
* @param permissionMode - Current permission/approval mode * @param permissionMode - Current permission/approval mode
* @param allowedBuiltinCommandNames - Optional array of allowed built-in command names.
* If not provided, defaults to empty array (only file commands will be included).
* @returns Promise resolving to CLISystemMessage * @returns Promise resolving to CLISystemMessage
*/ */
export async function buildSystemMessage( export async function buildSystemMessage(
config: Config, config: Config,
sessionId: string, sessionId: string,
permissionMode: PermissionMode, permissionMode: PermissionMode,
allowedBuiltinCommandNames?: string[],
): Promise<CLISystemMessage> { ): Promise<CLISystemMessage> {
const toolRegistry = config.getToolRegistry(); const toolRegistry = config.getToolRegistry();
const tools = toolRegistry ? toolRegistry.getAllToolNames() : []; const tools = toolRegistry ? toolRegistry.getAllToolNames() : [];
@@ -251,8 +256,11 @@ export async function buildSystemMessage(
})) }))
: []; : [];
// Load slash commands // Load slash commands with filtering based on allowed built-in commands
const slashCommands = await loadSlashCommandNames(config); const slashCommands = await loadSlashCommandNames(
config,
allowedBuiltinCommandNames,
);
// Load subagent names from config // Load subagent names from config
let agentNames: string[] = []; let agentNames: string[] = [];

View File

@@ -25,26 +25,26 @@ vi.mock('../../utils/request-tokenizer/index.js', () => ({
type AnthropicCreateArgs = [unknown, { signal?: AbortSignal }?]; type AnthropicCreateArgs = [unknown, { signal?: AbortSignal }?];
vi.mock('@anthropic-ai/sdk', () => { const anthropicMockState: {
const state: { constructorOptions?: Record<string, unknown>;
constructorOptions?: Record<string, unknown>; lastCreateArgs?: AnthropicCreateArgs;
lastCreateArgs?: AnthropicCreateArgs; createImpl: ReturnType<typeof vi.fn>;
createImpl: ReturnType<typeof vi.fn>; } = {
} = { constructorOptions: undefined,
constructorOptions: undefined, lastCreateArgs: undefined,
lastCreateArgs: undefined, createImpl: vi.fn(),
createImpl: vi.fn(), };
};
vi.mock('@anthropic-ai/sdk', () => {
class AnthropicMock { class AnthropicMock {
messages: { create: (...args: AnthropicCreateArgs) => unknown }; messages: { create: (...args: AnthropicCreateArgs) => unknown };
constructor(options: Record<string, unknown>) { constructor(options: Record<string, unknown>) {
state.constructorOptions = options; anthropicMockState.constructorOptions = options;
this.messages = { this.messages = {
create: (...args: AnthropicCreateArgs) => { create: (...args: AnthropicCreateArgs) => {
state.lastCreateArgs = args; anthropicMockState.lastCreateArgs = args;
return state.createImpl(...args); return anthropicMockState.createImpl(...args);
}, },
}; };
} }
@@ -52,7 +52,7 @@ vi.mock('@anthropic-ai/sdk', () => {
return { return {
default: AnthropicMock, default: AnthropicMock,
__anthropicState: state, __anthropicState: anthropicMockState,
}; };
}); });
@@ -89,9 +89,7 @@ describe('AnthropicContentGenerator', () => {
}, },
processingTime: 1, processingTime: 1,
}); });
// eslint-disable-next-line @typescript-eslint/no-explicit-any anthropicState = anthropicMockState;
const mod = (await import('@anthropic-ai/sdk')) as any;
anthropicState = mod.__anthropicState as typeof anthropicState;
anthropicState.createImpl.mockReset(); anthropicState.createImpl.mockReset();
anthropicState.lastCreateArgs = undefined; anthropicState.lastCreateArgs = undefined;
@@ -129,6 +127,68 @@ describe('AnthropicContentGenerator', () => {
); );
}); });
it('adds the effort beta header when reasoning.effort is set', async () => {
const { AnthropicContentGenerator } = await importGenerator();
void new AnthropicContentGenerator(
{
model: 'claude-test',
apiKey: 'test-key',
baseUrl: 'https://example.invalid',
timeout: 10_000,
maxRetries: 2,
samplingParams: {},
schemaCompliance: 'auto',
reasoning: { effort: 'medium' },
},
mockConfig,
);
const headers = (anthropicState.constructorOptions?.['defaultHeaders'] ||
{}) as Record<string, string>;
expect(headers['anthropic-beta']).toContain('effort-2025-11-24');
});
it('does not add the effort beta header when reasoning.effort is not set', async () => {
const { AnthropicContentGenerator } = await importGenerator();
void new AnthropicContentGenerator(
{
model: 'claude-test',
apiKey: 'test-key',
baseUrl: 'https://example.invalid',
timeout: 10_000,
maxRetries: 2,
samplingParams: {},
schemaCompliance: 'auto',
},
mockConfig,
);
const headers = (anthropicState.constructorOptions?.['defaultHeaders'] ||
{}) as Record<string, string>;
expect(headers['anthropic-beta']).not.toContain('effort-2025-11-24');
});
it('omits the anthropic beta header when reasoning is disabled', async () => {
const { AnthropicContentGenerator } = await importGenerator();
void new AnthropicContentGenerator(
{
model: 'claude-test',
apiKey: 'test-key',
baseUrl: 'https://example.invalid',
timeout: 10_000,
maxRetries: 2,
samplingParams: {},
schemaCompliance: 'auto',
reasoning: false,
},
mockConfig,
);
const headers = (anthropicState.constructorOptions?.['defaultHeaders'] ||
{}) as Record<string, string>;
expect(headers['anthropic-beta']).toBeUndefined();
});
describe('generateContent', () => { describe('generateContent', () => {
it('builds request with config sampling params (config overrides request) and thinking budget', async () => { it('builds request with config sampling params (config overrides request) and thinking budget', async () => {
const { AnthropicContentConverter } = await importConverter(); const { AnthropicContentConverter } = await importConverter();
@@ -167,7 +227,7 @@ describe('AnthropicContentGenerator', () => {
top_k: 20, top_k: 20,
}, },
schemaCompliance: 'auto', schemaCompliance: 'auto',
reasoning: { effort: 'high' }, reasoning: { effort: 'high', budget_tokens: 1000 },
}, },
mockConfig, mockConfig,
); );
@@ -202,6 +262,7 @@ describe('AnthropicContentGenerator', () => {
top_p: 0.9, top_p: 0.9,
top_k: 20, top_k: 20,
thinking: { type: 'enabled', budget_tokens: 1000 }, thinking: { type: 'enabled', budget_tokens: 1000 },
output_config: { effort: 'high' },
}), }),
); );

View File

@@ -39,6 +39,9 @@ type StreamingBlockState = {
type MessageCreateParamsWithThinking = MessageCreateParamsNonStreaming & { type MessageCreateParamsWithThinking = MessageCreateParamsNonStreaming & {
thinking?: { type: 'enabled'; budget_tokens: number }; thinking?: { type: 'enabled'; budget_tokens: number };
// Anthropic beta feature: output_config.effort (requires beta header effort-2025-11-24)
// This is not yet represented in the official SDK types we depend on.
output_config?: { effort: 'low' | 'medium' | 'high' };
}; };
export class AnthropicContentGenerator implements ContentGenerator { export class AnthropicContentGenerator implements ContentGenerator {
@@ -135,13 +138,32 @@ export class AnthropicContentGenerator implements ContentGenerator {
return false; return false;
} }
private buildHeaders(): Record<string, string | undefined> { private buildHeaders(): Record<string, string> {
const version = this.cliConfig.getCliVersion() || 'unknown'; const version = this.cliConfig.getCliVersion() || 'unknown';
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
return {
const betas: string[] = [];
const reasoning = this.contentGeneratorConfig.reasoning;
// Interleaved thinking is used when we send the `thinking` field.
if (reasoning !== false) {
betas.push('interleaved-thinking-2025-05-14');
}
// Effort (beta) is enabled when reasoning.effort is set.
if (reasoning !== false && reasoning?.effort !== undefined) {
betas.push('effort-2025-11-24');
}
const headers: Record<string, string> = {
'User-Agent': userAgent, 'User-Agent': userAgent,
'anthropic-beta': 'interleaved-thinking-2025-05-14',
}; };
if (betas.length) {
headers['anthropic-beta'] = betas.join(',');
}
return headers;
} }
private async buildRequest( private async buildRequest(
@@ -155,7 +177,8 @@ export class AnthropicContentGenerator implements ContentGenerator {
: undefined; : undefined;
const sampling = this.buildSamplingParameters(request); const sampling = this.buildSamplingParameters(request);
const thinking = this.buildThinkingConfig(request, sampling.max_tokens); const thinking = this.buildThinkingConfig(request);
const outputConfig = this.buildOutputConfig();
return { return {
model: this.contentGeneratorConfig.model, model: this.contentGeneratorConfig.model,
@@ -164,6 +187,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
tools, tools,
...sampling, ...sampling,
...(thinking ? { thinking } : {}), ...(thinking ? { thinking } : {}),
...(outputConfig ? { output_config: outputConfig } : {}),
}; };
} }
@@ -187,7 +211,8 @@ export class AnthropicContentGenerator implements ContentGenerator {
return configValue !== undefined ? configValue : requestValue; return configValue !== undefined ? configValue : requestValue;
}; };
const maxTokens = getParam<number>('max_tokens', 'maxOutputTokens') ?? 8192; const maxTokens =
getParam<number>('max_tokens', 'maxOutputTokens') ?? 10_000;
return { return {
max_tokens: maxTokens, max_tokens: maxTokens,
@@ -199,7 +224,6 @@ export class AnthropicContentGenerator implements ContentGenerator {
private buildThinkingConfig( private buildThinkingConfig(
request: GenerateContentParameters, request: GenerateContentParameters,
maxTokens: number,
): { type: 'enabled'; budget_tokens: number } | undefined { ): { type: 'enabled'; budget_tokens: number } | undefined {
if (request.config?.thinkingConfig?.includeThoughts === false) { if (request.config?.thinkingConfig?.includeThoughts === false) {
return undefined; return undefined;
@@ -219,9 +243,9 @@ export class AnthropicContentGenerator implements ContentGenerator {
} }
const effort = reasoning?.effort; const effort = reasoning?.effort;
const baseBudget = // When using interleaved thinking with tools, this budget token limit is the entire context window(200k tokens).
effort === 'low' ? 1024 : effort === 'high' ? 4096 : 2048; const budgetTokens =
const budgetTokens = Math.min(baseBudget, Math.max(1, maxTokens)); effort === 'low' ? 16_000 : effort === 'high' ? 64_000 : 32_000;
return { return {
type: 'enabled', type: 'enabled',
@@ -229,6 +253,21 @@ export class AnthropicContentGenerator implements ContentGenerator {
}; };
} }
private buildOutputConfig():
| { effort: 'low' | 'medium' | 'high' }
| undefined {
const reasoning = this.contentGeneratorConfig.reasoning;
if (reasoning === false || reasoning === undefined) {
return undefined;
}
if (reasoning.effort === undefined) {
return undefined;
}
return { effort: reasoning.effort };
}
private async *processStream( private async *processStream(
stream: AsyncIterable<RawMessageStreamEvent>, stream: AsyncIterable<RawMessageStreamEvent>,
): AsyncGenerator<GenerateContentResponse> { ): AsyncGenerator<GenerateContentResponse> {

View File

@@ -542,4 +542,206 @@ describe('OpenAIContentConverter', () => {
expect(original).toEqual(originalCopy); expect(original).toEqual(originalCopy);
}); });
}); });
describe('mergeConsecutiveAssistantMessages', () => {
it('should merge two consecutive assistant messages with string content', () => {
const request: GenerateContentParameters = {
model: 'models/test',
contents: [
{
role: 'model',
parts: [{ text: 'First part' }],
},
{
role: 'model',
parts: [{ text: 'Second part' }],
},
],
};
const messages = converter.convertGeminiRequestToOpenAI(request);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe('assistant');
const content = messages[0]
.content as OpenAI.Chat.ChatCompletionContentPart[];
expect(content).toHaveLength(2);
expect(content[0]).toEqual({ type: 'text', text: 'First part' });
expect(content[1]).toEqual({ type: 'text', text: 'Second part' });
});
it('should merge multiple consecutive assistant messages', () => {
const request: GenerateContentParameters = {
model: 'models/test',
contents: [
{
role: 'model',
parts: [{ text: 'Part 1' }],
},
{
role: 'model',
parts: [{ text: 'Part 2' }],
},
{
role: 'model',
parts: [{ text: 'Part 3' }],
},
],
};
const messages = converter.convertGeminiRequestToOpenAI(request);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe('assistant');
const content = messages[0]
.content as OpenAI.Chat.ChatCompletionContentPart[];
expect(content).toHaveLength(3);
});
it('should merge tool_calls from consecutive assistant messages', () => {
const request: GenerateContentParameters = {
model: 'models/test',
contents: [
{
role: 'model',
parts: [
{
functionCall: {
id: 'call_1',
name: 'tool_1',
args: {},
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'call_1',
name: 'tool_1',
response: { output: 'result_1' },
},
},
],
},
{
role: 'model',
parts: [
{
functionCall: {
id: 'call_2',
name: 'tool_2',
args: {},
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'call_2',
name: 'tool_2',
response: { output: 'result_2' },
},
},
],
},
],
};
const messages = converter.convertGeminiRequestToOpenAI(request);
// Should have: assistant (tool_call_1), tool (result_1), assistant (tool_call_2), tool (result_2)
expect(messages).toHaveLength(4);
expect(messages[0].role).toBe('assistant');
expect(messages[1].role).toBe('tool');
expect(messages[2].role).toBe('assistant');
expect(messages[3].role).toBe('tool');
});
it('should not merge assistant messages separated by user messages', () => {
const request: GenerateContentParameters = {
model: 'models/test',
contents: [
{
role: 'model',
parts: [{ text: 'First assistant' }],
},
{
role: 'user',
parts: [{ text: 'User message' }],
},
{
role: 'model',
parts: [{ text: 'Second assistant' }],
},
],
};
const messages = converter.convertGeminiRequestToOpenAI(request);
expect(messages).toHaveLength(3);
expect(messages[0].role).toBe('assistant');
expect(messages[1].role).toBe('user');
expect(messages[2].role).toBe('assistant');
});
it('should handle merging when one message has array content and another has string', () => {
const request: GenerateContentParameters = {
model: 'models/test',
contents: [
{
role: 'model',
parts: [{ text: 'Text part' }],
},
{
role: 'model',
parts: [{ text: 'Another text' }],
},
],
};
const messages = converter.convertGeminiRequestToOpenAI(request);
expect(messages).toHaveLength(1);
const content = messages[0]
.content as OpenAI.Chat.ChatCompletionContentPart[];
expect(Array.isArray(content)).toBe(true);
expect(content).toHaveLength(2);
});
it('should merge empty content correctly', () => {
const request: GenerateContentParameters = {
model: 'models/test',
contents: [
{
role: 'model',
parts: [{ text: 'First' }],
},
{
role: 'model',
parts: [],
},
{
role: 'model',
parts: [{ text: 'Second' }],
},
],
};
const messages = converter.convertGeminiRequestToOpenAI(request);
// Empty messages should be filtered out
expect(messages).toHaveLength(1);
const content = messages[0]
.content as OpenAI.Chat.ChatCompletionContentPart[];
expect(content).toHaveLength(2);
expect(content[0]).toEqual({ type: 'text', text: 'First' });
expect(content[1]).toEqual({ type: 'text', text: 'Second' });
});
});
}); });

View File

@@ -1120,12 +1120,44 @@ export class OpenAIContentConverter {
// If the last message is also an assistant message, merge them // If the last message is also an assistant message, merge them
if (lastMessage.role === 'assistant') { if (lastMessage.role === 'assistant') {
// Combine content // Combine content
const combinedContent = [ const lastContent = lastMessage.content;
typeof lastMessage.content === 'string' ? lastMessage.content : '', const currentContent = message.content;
typeof message.content === 'string' ? message.content : '',
] // Determine if we should use array format (if either content is an array)
.filter(Boolean) const useArrayFormat =
.join(''); Array.isArray(lastContent) || Array.isArray(currentContent);
let combinedContent:
| string
| OpenAI.Chat.ChatCompletionContentPart[]
| null;
if (useArrayFormat) {
// Convert both to array format and merge
const lastParts = Array.isArray(lastContent)
? lastContent
: typeof lastContent === 'string' && lastContent
? [{ type: 'text' as const, text: lastContent }]
: [];
const currentParts = Array.isArray(currentContent)
? currentContent
: typeof currentContent === 'string' && currentContent
? [{ type: 'text' as const, text: currentContent }]
: [];
combinedContent = [
...lastParts,
...currentParts,
] as OpenAI.Chat.ChatCompletionContentPart[];
} else {
// Both are strings or null, merge as strings
const lastText = typeof lastContent === 'string' ? lastContent : '';
const currentText =
typeof currentContent === 'string' ? currentContent : '';
const mergedText = [lastText, currentText].filter(Boolean).join('');
combinedContent = mergedText || null;
}
// Combine tool calls // Combine tool calls
const lastToolCalls = const lastToolCalls =
@@ -1137,14 +1169,17 @@ export class OpenAIContentConverter {
// Update the last message with combined data // Update the last message with combined data
( (
lastMessage as OpenAI.Chat.ChatCompletionMessageParam & { lastMessage as OpenAI.Chat.ChatCompletionMessageParam & {
content: string | null; content: string | OpenAI.Chat.ChatCompletionContentPart[] | null;
tool_calls?: OpenAI.Chat.ChatCompletionMessageToolCall[]; tool_calls?: OpenAI.Chat.ChatCompletionMessageToolCall[];
} }
).content = combinedContent || null; ).content = combinedContent || null;
if (combinedToolCalls.length > 0) { if (combinedToolCalls.length > 0) {
( (
lastMessage as OpenAI.Chat.ChatCompletionMessageParam & { lastMessage as OpenAI.Chat.ChatCompletionMessageParam & {
content: string | null; content:
| string
| OpenAI.Chat.ChatCompletionContentPart[]
| null;
tool_calls?: OpenAI.Chat.ChatCompletionMessageToolCall[]; tool_calls?: OpenAI.Chat.ChatCompletionMessageToolCall[];
} }
).tool_calls = combinedToolCalls; ).tool_calls = combinedToolCalls;

View File

@@ -145,9 +145,7 @@ export class DashScopeOpenAICompatibleProvider
getDefaultGenerationConfig(): GenerateContentConfig { getDefaultGenerationConfig(): GenerateContentConfig {
return { return {
temperature: 0.7, temperature: 0.3,
topP: 0.8,
topK: 20,
}; };
} }

View File

@@ -169,6 +169,44 @@ describe('ShellTool', () => {
}); });
expect(invocation.getDescription()).not.toContain('[background]'); expect(invocation.getDescription()).not.toContain('[background]');
}); });
describe('is_background parameter coercion', () => {
it('should accept string "true" as boolean true', () => {
const invocation = shellTool.build({
command: 'npm run dev',
is_background: 'true' as unknown as boolean,
});
expect(invocation).toBeDefined();
expect(invocation.getDescription()).toContain('[background]');
});
it('should accept string "false" as boolean false', () => {
const invocation = shellTool.build({
command: 'npm run build',
is_background: 'false' as unknown as boolean,
});
expect(invocation).toBeDefined();
expect(invocation.getDescription()).not.toContain('[background]');
});
it('should accept string "True" as boolean true', () => {
const invocation = shellTool.build({
command: 'npm run dev',
is_background: 'True' as unknown as boolean,
});
expect(invocation).toBeDefined();
expect(invocation.getDescription()).toContain('[background]');
});
it('should accept string "False" as boolean false', () => {
const invocation = shellTool.build({
command: 'npm run build',
is_background: 'False' as unknown as boolean,
});
expect(invocation).toBeDefined();
expect(invocation.getDescription()).not.toContain('[background]');
});
});
}); });
describe('execute', () => { describe('execute', () => {

View File

@@ -122,4 +122,91 @@ describe('SchemaValidator', () => {
}; };
expect(SchemaValidator.validate(schema, params)).not.toBeNull(); expect(SchemaValidator.validate(schema, params)).not.toBeNull();
}); });
describe('boolean string coercion', () => {
const booleanSchema = {
type: 'object',
properties: {
is_background: {
type: 'boolean',
},
},
required: ['is_background'],
};
it('should coerce string "true" to boolean true', () => {
const params = { is_background: 'true' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(true);
});
it('should coerce string "True" to boolean true', () => {
const params = { is_background: 'True' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(true);
});
it('should coerce string "TRUE" to boolean true', () => {
const params = { is_background: 'TRUE' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(true);
});
it('should coerce string "false" to boolean false', () => {
const params = { is_background: 'false' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(false);
});
it('should coerce string "False" to boolean false', () => {
const params = { is_background: 'False' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(false);
});
it('should coerce string "FALSE" to boolean false', () => {
const params = { is_background: 'FALSE' };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(false);
});
it('should handle nested objects with string booleans', () => {
const nestedSchema = {
type: 'object',
properties: {
options: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
},
},
},
};
const params = { options: { enabled: 'true' } };
expect(SchemaValidator.validate(nestedSchema, params)).toBeNull();
expect((params.options as unknown as { enabled: boolean }).enabled).toBe(
true,
);
});
it('should not affect non-boolean strings', () => {
const mixedSchema = {
type: 'object',
properties: {
name: { type: 'string' },
is_active: { type: 'boolean' },
},
};
const params = { name: 'trueman', is_active: 'true' };
expect(SchemaValidator.validate(mixedSchema, params)).toBeNull();
expect(params.name).toBe('trueman');
expect(params.is_active).toBe(true);
});
it('should pass through actual boolean values unchanged', () => {
const params = { is_background: true };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
expect(params.is_background).toBe(true);
});
});
}); });

View File

@@ -41,14 +41,12 @@ export class SchemaValidator {
return 'Value of params must be an object'; return 'Value of params must be an object';
} }
const validate = ajValidator.compile(schema); const validate = ajValidator.compile(schema);
const valid = validate(data); let valid = validate(data);
if (!valid && validate.errors) { if (!valid && validate.errors) {
// Find any True or False values and lowercase them // Coerce string boolean values ("true"/"false") to actual booleans
fixBooleanCasing(data as Record<string, unknown>); fixBooleanValues(data as Record<string, unknown>);
const validate = ajValidator.compile(schema);
const valid = validate(data);
valid = validate(data);
if (!valid && validate.errors) { if (!valid && validate.errors) {
return ajValidator.errorsText(validate.errors, { dataVar: 'params' }); return ajValidator.errorsText(validate.errors, { dataVar: 'params' });
} }
@@ -57,13 +55,29 @@ export class SchemaValidator {
} }
} }
function fixBooleanCasing(data: Record<string, unknown>) { /**
* Coerces string boolean values to actual booleans.
* This handles cases where LLMs return "true"/"false" strings instead of boolean values,
* which is common with self-hosted LLMs.
*
* Converts:
* - "true", "True", "TRUE" -> true
* - "false", "False", "FALSE" -> false
*/
function fixBooleanValues(data: Record<string, unknown>) {
for (const key of Object.keys(data)) { for (const key of Object.keys(data)) {
if (!(key in data)) continue; if (!(key in data)) continue;
const value = data[key];
if (typeof data[key] === 'object') { if (typeof value === 'object' && value !== null) {
fixBooleanCasing(data[key] as Record<string, unknown>); fixBooleanValues(value as Record<string, unknown>);
} else if (data[key] === 'True') data[key] = 'true'; } else if (typeof value === 'string') {
else if (data[key] === 'False') data[key] = 'false'; const lower = value.toLowerCase();
if (lower === 'true') {
data[key] = true;
} else if (lower === 'false') {
data[key] = false;
}
}
} }
} }

View File

@@ -272,8 +272,6 @@ export class Query implements AsyncIterable<SDKMessage> {
// Get only successfully connected SDK servers for CLI // Get only successfully connected SDK servers for CLI
const sdkMcpServersForCli = this.getSdkMcpServersForCli(); const sdkMcpServersForCli = this.getSdkMcpServersForCli();
const mcpServersForCli = this.getMcpServersForCli(); const mcpServersForCli = this.getMcpServersForCli();
logger.debug('SDK MCP servers for CLI:', sdkMcpServersForCli);
logger.debug('External MCP servers for CLI:', mcpServersForCli);
await this.sendControlRequest(ControlRequestType.INITIALIZE, { await this.sendControlRequest(ControlRequestType.INITIALIZE, {
hooks: null, hooks: null,
@@ -629,6 +627,11 @@ export class Query implements AsyncIterable<SDKMessage> {
return Promise.reject(new Error('Query is closed')); return Promise.reject(new Error('Query is closed'));
} }
if (subtype !== ControlRequestType.INITIALIZE) {
// Ensure all other control requests get processed after initialization
await this.initialized;
}
const requestId = randomUUID(); const requestId = randomUUID();
const request: CLIControlRequest = { const request: CLIControlRequest = {

View File

@@ -50,6 +50,9 @@ vi.mock('vscode', () => ({
registerTextDocumentContentProvider: vi.fn(), registerTextDocumentContentProvider: vi.fn(),
onDidChangeWorkspaceFolders: vi.fn(), onDidChangeWorkspaceFolders: vi.fn(),
onDidGrantWorkspaceTrust: vi.fn(), onDidGrantWorkspaceTrust: vi.fn(),
registerFileSystemProvider: vi.fn(() => ({
dispose: vi.fn(),
})),
}, },
commands: { commands: {
registerCommand: vi.fn(), registerCommand: vi.fn(),

View File

@@ -16,6 +16,7 @@ import {
} from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js';
import { WebViewProvider } from './webview/WebViewProvider.js'; import { WebViewProvider } from './webview/WebViewProvider.js';
import { registerNewCommands } from './commands/index.js'; import { registerNewCommands } from './commands/index.js';
import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js';
const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion'; const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion';
const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown'; const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown';
@@ -110,6 +111,19 @@ export async function activate(context: vscode.ExtensionContext) {
checkForUpdates(context, log); checkForUpdates(context, log);
// Create and register readonly file system provider
// The provider registers itself as a singleton in the constructor
const readonlyProvider = new ReadonlyFileSystemProvider();
context.subscriptions.push(
vscode.workspace.registerFileSystemProvider(
ReadonlyFileSystemProvider.getScheme(),
readonlyProvider,
{ isCaseSensitive: true, isReadonly: true },
),
readonlyProvider,
);
log('Readonly file system provider registered');
const diffContentProvider = new DiffContentProvider(); const diffContentProvider = new DiffContentProvider();
const diffManager = new DiffManager( const diffManager = new DiffManager(
log, log,

View File

@@ -38,6 +38,10 @@ vi.mock('node:os', async (importOriginal) => {
}; };
}); });
vi.mock('@qwen-code/qwen-code-core/src/ide/detect-ide.js', () => ({
detectIdeFromEnv: vi.fn(() => ({ name: 'vscode', displayName: 'VS Code' })),
}));
const vscodeMock = vi.hoisted(() => ({ const vscodeMock = vi.hoisted(() => ({
workspace: { workspace: {
workspaceFolders: [ workspaceFolders: [
@@ -58,6 +62,13 @@ const vscodeMock = vi.hoisted(() => ({
vi.mock('vscode', () => vscodeMock); vi.mock('vscode', () => vscodeMock);
vi.mock('@qwen-code/qwen-code-core/src/ide/detect-ide.js', () => ({
detectIdeFromEnv: vi.fn(() => ({
name: 'vscode',
displayName: 'VS Code',
})),
}));
vi.mock('./open-files-manager', () => { vi.mock('./open-files-manager', () => {
const OpenFilesManager = vi.fn(); const OpenFilesManager = vi.fn();
OpenFilesManager.prototype.onDidChange = vi.fn(() => ({ dispose: vi.fn() })); OpenFilesManager.prototype.onDidChange = vi.fn(() => ({ dispose: vi.fn() }));

View File

@@ -146,6 +146,8 @@ export class AcpConnection {
console.error( console.error(
`[ACP qwen] Process exited with code: ${code}, signal: ${signal}`, `[ACP qwen] Process exited with code: ${code}, signal: ${signal}`,
); );
// Clear pending requests when process exits
this.pendingRequests.clear();
}); });
// Wait for process to start // Wait for process to start

View File

@@ -8,6 +8,7 @@ import type {
AcpSessionUpdate, AcpSessionUpdate,
AcpPermissionRequest, AcpPermissionRequest,
AuthenticateUpdateNotification, AuthenticateUpdateNotification,
ModelInfo,
} from '../types/acpTypes.js'; } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js'; import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js';
@@ -17,6 +18,7 @@ import type {
PlanEntry, PlanEntry,
ToolCallUpdateData, ToolCallUpdateData,
QwenAgentCallbacks, QwenAgentCallbacks,
UsageStatsPayload,
} from '../types/chatTypes.js'; } from '../types/chatTypes.js';
import { import {
QwenConnectionHandler, QwenConnectionHandler,
@@ -24,6 +26,7 @@ import {
} from '../services/qwenConnectionHandler.js'; } from '../services/qwenConnectionHandler.js';
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js'; import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
import { authMethod } from '../types/acpTypes.js'; import { authMethod } from '../types/acpTypes.js';
import { extractModelInfoFromNewSessionResult } from '../utils/acpModelInfo.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js'; import { isAuthenticationRequiredError } from '../utils/authErrors.js';
import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js'; import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js';
@@ -195,12 +198,16 @@ export class QwenAgentManager {
options?: AgentConnectOptions, options?: AgentConnectOptions,
): Promise<QwenConnectionResult> { ): Promise<QwenConnectionResult> {
this.currentWorkingDir = workingDir; this.currentWorkingDir = workingDir;
return this.connectionHandler.connect( const res = await this.connectionHandler.connect(
this.connection, this.connection,
workingDir, workingDir,
cliEntryPath, cliEntryPath,
options, options,
); );
if (res.modelInfo && this.callbacks.onModelInfo) {
this.callbacks.onModelInfo(res.modelInfo);
}
return res;
} }
/** /**
@@ -1091,9 +1098,10 @@ export class QwenAgentManager {
this.sessionCreateInFlight = (async () => { this.sessionCreateInFlight = (async () => {
try { try {
let newSessionResult: unknown;
// Try to create a new ACP session. If Qwen asks for auth, let it handle authentication. // Try to create a new ACP session. If Qwen asks for auth, let it handle authentication.
try { try {
await this.connection.newSession(workingDir); newSessionResult = await this.connection.newSession(workingDir);
} catch (err) { } catch (err) {
const requiresAuth = isAuthenticationRequiredError(err); const requiresAuth = isAuthenticationRequiredError(err);
@@ -1115,7 +1123,7 @@ export class QwenAgentManager {
); );
// Add a slight delay to ensure auth state is settled // Add a slight delay to ensure auth state is settled
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
await this.connection.newSession(workingDir); newSessionResult = await this.connection.newSession(workingDir);
} catch (reauthErr) { } catch (reauthErr) {
console.error( console.error(
'[QwenAgentManager] Re-authentication failed:', '[QwenAgentManager] Re-authentication failed:',
@@ -1127,6 +1135,13 @@ export class QwenAgentManager {
throw err; throw err;
} }
} }
const modelInfo =
extractModelInfoFromNewSessionResult(newSessionResult);
if (modelInfo && this.callbacks.onModelInfo) {
this.callbacks.onModelInfo(modelInfo);
}
const newSessionId = this.connection.currentSessionId; const newSessionId = this.connection.currentSessionId;
console.log( console.log(
'[QwenAgentManager] New session created with ID:', '[QwenAgentManager] New session created with ID:',
@@ -1257,6 +1272,22 @@ export class QwenAgentManager {
this.sessionUpdateHandler.updateCallbacks(this.callbacks); this.sessionUpdateHandler.updateCallbacks(this.callbacks);
} }
/**
* Register callback for usage metadata updates
*/
onUsageUpdate(callback: (stats: UsageStatsPayload) => void): void {
this.callbacks.onUsageUpdate = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* Register callback for model info updates
*/
onModelInfo(callback: (info: ModelInfo) => void): void {
this.callbacks.onModelInfo = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/** /**
* Disconnect * Disconnect
*/ */

View File

@@ -13,10 +13,13 @@
import type { AcpConnection } from './acpConnection.js'; import type { AcpConnection } from './acpConnection.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js'; import { isAuthenticationRequiredError } from '../utils/authErrors.js';
import { authMethod } from '../types/acpTypes.js'; import { authMethod } from '../types/acpTypes.js';
import { extractModelInfoFromNewSessionResult } from '../utils/acpModelInfo.js';
import type { ModelInfo } from '../types/acpTypes.js';
export interface QwenConnectionResult { export interface QwenConnectionResult {
sessionCreated: boolean; sessionCreated: boolean;
requiresAuth: boolean; requiresAuth: boolean;
modelInfo?: ModelInfo;
} }
/** /**
@@ -44,6 +47,7 @@ export class QwenConnectionHandler {
const autoAuthenticate = options?.autoAuthenticate ?? true; const autoAuthenticate = options?.autoAuthenticate ?? true;
let sessionCreated = false; let sessionCreated = false;
let requiresAuth = false; let requiresAuth = false;
let modelInfo: ModelInfo | undefined;
// Build extra CLI arguments (only essential parameters) // Build extra CLI arguments (only essential parameters)
const extraArgs: string[] = []; const extraArgs: string[] = [];
@@ -66,13 +70,15 @@ export class QwenConnectionHandler {
console.log( console.log(
'[QwenAgentManager] Creating new session (letting CLI handle authentication)...', '[QwenAgentManager] Creating new session (letting CLI handle authentication)...',
); );
await this.newSessionWithRetry( const newSessionResult = await this.newSessionWithRetry(
connection, connection,
workingDir, workingDir,
3, 3,
authMethod, authMethod,
autoAuthenticate, autoAuthenticate,
); );
modelInfo =
extractModelInfoFromNewSessionResult(newSessionResult) || undefined;
console.log('[QwenAgentManager] New session created successfully'); console.log('[QwenAgentManager] New session created successfully');
sessionCreated = true; sessionCreated = true;
} catch (sessionError) { } catch (sessionError) {
@@ -99,7 +105,7 @@ export class QwenConnectionHandler {
console.log(`\n========================================`); console.log(`\n========================================`);
console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`); console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`);
console.log(`========================================\n`); console.log(`========================================\n`);
return { sessionCreated, requiresAuth }; return { sessionCreated, requiresAuth, modelInfo };
} }
/** /**
@@ -115,15 +121,15 @@ export class QwenConnectionHandler {
maxRetries: number, maxRetries: number,
authMethod: string, authMethod: string,
autoAuthenticate: boolean, autoAuthenticate: boolean,
): Promise<void> { ): Promise<unknown> {
for (let attempt = 1; attempt <= maxRetries; attempt++) { for (let attempt = 1; attempt <= maxRetries; attempt++) {
try { try {
console.log( console.log(
`[QwenAgentManager] Creating session (attempt ${attempt}/${maxRetries})...`, `[QwenAgentManager] Creating session (attempt ${attempt}/${maxRetries})...`,
); );
await connection.newSession(workingDir); const res = await connection.newSession(workingDir);
console.log('[QwenAgentManager] Session created successfully'); console.log('[QwenAgentManager] Session created successfully');
return; return res;
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
@@ -155,11 +161,11 @@ export class QwenConnectionHandler {
'[QwenAgentManager] newSessionWithRetry Authentication successful', '[QwenAgentManager] newSessionWithRetry Authentication successful',
); );
// Retry immediately after successful auth // Retry immediately after successful auth
await connection.newSession(workingDir); const res = await connection.newSession(workingDir);
console.log( console.log(
'[QwenAgentManager] Session created successfully after auth', '[QwenAgentManager] Session created successfully after auth',
); );
return; return res;
} catch (authErr) { } catch (authErr) {
console.error( console.error(
'[QwenAgentManager] Re-authentication failed:', '[QwenAgentManager] Re-authentication failed:',
@@ -180,5 +186,7 @@ export class QwenConnectionHandler {
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
} }
} }
throw new Error('Session creation failed unexpectedly');
} }
} }

View File

@@ -10,9 +10,12 @@
* Handles session updates from ACP and dispatches them to appropriate callbacks * Handles session updates from ACP and dispatches them to appropriate callbacks
*/ */
import type { AcpSessionUpdate } from '../types/acpTypes.js'; import type { AcpSessionUpdate, SessionUpdateMeta } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { QwenAgentCallbacks } from '../types/chatTypes.js'; import type {
QwenAgentCallbacks,
UsageStatsPayload,
} from '../types/chatTypes.js';
/** /**
* Qwen Session Update Handler class * Qwen Session Update Handler class
@@ -57,6 +60,7 @@ export class QwenSessionUpdateHandler {
if (update.content?.text && this.callbacks.onStreamChunk) { if (update.content?.text && this.callbacks.onStreamChunk) {
this.callbacks.onStreamChunk(update.content.text); this.callbacks.onStreamChunk(update.content.text);
} }
this.emitUsageMeta(update._meta);
break; break;
case 'agent_thought_chunk': case 'agent_thought_chunk':
@@ -71,6 +75,7 @@ export class QwenSessionUpdateHandler {
this.callbacks.onStreamChunk(update.content.text); this.callbacks.onStreamChunk(update.content.text);
} }
} }
this.emitUsageMeta(update._meta);
break; break;
case 'tool_call': { case 'tool_call': {
@@ -160,4 +165,17 @@ export class QwenSessionUpdateHandler {
break; break;
} }
} }
private emitUsageMeta(meta?: SessionUpdateMeta): void {
if (!meta || !this.callbacks.onUsageUpdate) {
return;
}
const payload: UsageStatsPayload = {
usage: meta.usage || undefined,
durationMs: meta.durationMs ?? undefined,
};
this.callbacks.onUsageUpdate(payload);
}
} }

View File

@@ -0,0 +1,204 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
/**
* Readonly file system provider for temporary files
* Uses custom URI scheme to create readonly documents in VS Code
*/
export class ReadonlyFileSystemProvider
implements vscode.FileSystemProvider, vscode.Disposable
{
private static readonly scheme = 'qwen-readonly';
private static instance: ReadonlyFileSystemProvider | null = null;
private readonly files = new Map<string, Uint8Array>();
private readonly emitter = new vscode.EventEmitter<
vscode.FileChangeEvent[]
>();
private readonly disposables: vscode.Disposable[] = [];
readonly onDidChangeFile = this.emitter.event;
constructor() {
// Ensure only one instance exists
if (ReadonlyFileSystemProvider.instance !== null) {
console.warn(
'[ReadonlyFileSystemProvider] Instance already exists, replacing with new instance',
);
}
this.disposables.push(this.emitter);
// Register as global singleton
ReadonlyFileSystemProvider.instance = this;
}
static getScheme(): string {
return ReadonlyFileSystemProvider.scheme;
}
/**
* Get the global singleton instance
* Returns null if not initialized yet
*/
static getInstance(): ReadonlyFileSystemProvider | null {
return ReadonlyFileSystemProvider.instance;
}
/**
* Create a URI for a readonly temporary file (static version)
*/
static createUri(fileName: string, content: string): vscode.Uri {
// For tool-call related filenames, keep the URI stable so repeated clicks focus the same document.
// Note: toolCallId can include underscores (e.g. "call_..."), so match everything after the prefix.
const isToolCallFile =
/^(bash-input|bash-output|execute-input|execute-output)-.+$/.test(
fileName,
);
if (isToolCallFile) {
return vscode.Uri.from({
scheme: ReadonlyFileSystemProvider.scheme,
path: `/${fileName}`,
});
}
// For other cases, keep the original approach with timestamp to avoid collisions.
const timestamp = Date.now();
const hash = Buffer.from(content.substring(0, 100)).toString('base64url');
const uniqueId = `${timestamp}-${hash.substring(0, 8)}`;
return vscode.Uri.from({
scheme: ReadonlyFileSystemProvider.scheme,
path: `/${fileName}-${uniqueId}`,
});
}
/**
* Create a URI for a readonly temporary file (instance method)
*/
createUri(fileName: string, content: string): vscode.Uri {
return ReadonlyFileSystemProvider.createUri(fileName, content);
}
/**
* Set content for a URI
*/
setContent(uri: vscode.Uri, content: string): void {
const buffer = Buffer.from(content, 'utf8');
const key = uri.toString();
const existed = this.files.has(key);
this.files.set(key, buffer);
this.emitter.fire([
{
type: existed
? vscode.FileChangeType.Changed
: vscode.FileChangeType.Created,
uri,
},
]);
}
/**
* Get content for a URI
*/
getContent(uri: vscode.Uri): string | undefined {
const buffer = this.files.get(uri.toString());
return buffer ? Buffer.from(buffer).toString('utf8') : undefined;
}
// FileSystemProvider implementation
watch(): vscode.Disposable {
// No watching needed for readonly files
return new vscode.Disposable(() => {});
}
stat(uri: vscode.Uri): vscode.FileStat {
const buffer = this.files.get(uri.toString());
if (!buffer) {
throw vscode.FileSystemError.FileNotFound(uri);
}
return {
type: vscode.FileType.File,
ctime: Date.now(),
mtime: Date.now(),
size: buffer.byteLength,
};
}
readDirectory(): Array<[string, vscode.FileType]> {
// Not needed for our use case
return [];
}
createDirectory(): void {
throw vscode.FileSystemError.NoPermissions('Readonly file system');
}
readFile(uri: vscode.Uri): Uint8Array {
const buffer = this.files.get(uri.toString());
if (!buffer) {
throw vscode.FileSystemError.FileNotFound(uri);
}
return buffer;
}
writeFile(
uri: vscode.Uri,
content: Uint8Array,
options: { create: boolean; overwrite: boolean },
): void {
// Check if file exists
const exists = this.files.has(uri.toString());
// For readonly files, only allow creation, not modification
if (exists && !options.overwrite) {
throw vscode.FileSystemError.FileExists(uri);
}
if (!exists && !options.create) {
throw vscode.FileSystemError.FileNotFound(uri);
}
this.files.set(uri.toString(), content);
this.emitter.fire([
{
type: exists
? vscode.FileChangeType.Changed
: vscode.FileChangeType.Created,
uri,
},
]);
}
delete(uri: vscode.Uri): void {
if (!this.files.has(uri.toString())) {
throw vscode.FileSystemError.FileNotFound(uri);
}
this.files.delete(uri.toString());
this.emitter.fire([{ type: vscode.FileChangeType.Deleted, uri }]);
}
rename(): void {
throw vscode.FileSystemError.NoPermissions('Readonly file system');
}
/**
* Clear all cached files
*/
clear(): void {
this.files.clear();
}
dispose(): void {
this.clear();
this.disposables.forEach((d) => d.dispose());
// Clear global instance on dispose
if (ReadonlyFileSystemProvider.instance === this) {
ReadonlyFileSystemProvider.instance = null;
}
}
}

View File

@@ -48,6 +48,35 @@ export interface ContentBlock {
uri?: string; uri?: string;
} }
export interface UsageMetadata {
promptTokens?: number | null;
completionTokens?: number | null;
thoughtsTokens?: number | null;
totalTokens?: number | null;
cachedTokens?: number | null;
}
export interface SessionUpdateMeta {
usage?: UsageMetadata | null;
durationMs?: number | null;
}
export type AcpMeta = Record<string, unknown>;
export type ModelId = string;
export interface ModelInfo {
_meta?: AcpMeta | null;
description?: string | null;
modelId: ModelId;
name: string;
}
export interface SessionModelState {
_meta?: AcpMeta | null;
availableModels: ModelInfo[];
currentModelId: ModelId;
}
export interface UserMessageChunkUpdate extends BaseSessionUpdate { export interface UserMessageChunkUpdate extends BaseSessionUpdate {
update: { update: {
sessionUpdate: 'user_message_chunk'; sessionUpdate: 'user_message_chunk';
@@ -59,6 +88,7 @@ export interface AgentMessageChunkUpdate extends BaseSessionUpdate {
update: { update: {
sessionUpdate: 'agent_message_chunk'; sessionUpdate: 'agent_message_chunk';
content: ContentBlock; content: ContentBlock;
_meta?: SessionUpdateMeta;
}; };
} }
@@ -66,6 +96,7 @@ export interface AgentThoughtChunkUpdate extends BaseSessionUpdate {
update: { update: {
sessionUpdate: 'agent_thought_chunk'; sessionUpdate: 'agent_thought_chunk';
content: ContentBlock; content: ContentBlock;
_meta?: SessionUpdateMeta;
}; };
} }

View File

@@ -3,7 +3,7 @@
* Copyright 2025 Qwen Team * Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { AcpPermissionRequest } from './acpTypes.js'; import type { AcpPermissionRequest, ModelInfo } from './acpTypes.js';
import type { ApprovalModeValue } from './approvalModeValueTypes.js'; import type { ApprovalModeValue } from './approvalModeValueTypes.js';
export interface ChatMessage { export interface ChatMessage {
@@ -28,6 +28,18 @@ export interface ToolCallUpdateData {
locations?: Array<{ path: string; line?: number | null }>; locations?: Array<{ path: string; line?: number | null }>;
} }
export interface UsageStatsPayload {
usage?: {
promptTokens?: number | null;
completionTokens?: number | null;
thoughtsTokens?: number | null;
totalTokens?: number | null;
cachedTokens?: number | null;
} | null;
durationMs?: number | null;
tokenLimit?: number | null;
}
export interface QwenAgentCallbacks { export interface QwenAgentCallbacks {
onMessage?: (message: ChatMessage) => void; onMessage?: (message: ChatMessage) => void;
onStreamChunk?: (chunk: string) => void; onStreamChunk?: (chunk: string) => void;
@@ -45,6 +57,8 @@ export interface QwenAgentCallbacks {
}>; }>;
}) => void; }) => void;
onModeChanged?: (modeId: ApprovalModeValue) => void; onModeChanged?: (modeId: ApprovalModeValue) => void;
onUsageUpdate?: (stats: UsageStatsPayload) => void;
onModelInfo?: (info: ModelInfo) => void;
} }
export interface ToolCallUpdate { export interface ToolCallUpdate {

View File

@@ -0,0 +1,77 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { extractModelInfoFromNewSessionResult } from './acpModelInfo.js';
describe('extractModelInfoFromNewSessionResult', () => {
it('extracts from NewSessionResponse.models (SessionModelState)', () => {
expect(
extractModelInfoFromNewSessionResult({
sessionId: 's',
models: {
currentModelId: 'qwen3-coder-plus',
availableModels: [
{
modelId: 'qwen3-coder-plus',
name: 'Qwen3 Coder Plus',
description: null,
_meta: { contextLimit: 123 },
},
],
},
}),
).toEqual({
modelId: 'qwen3-coder-plus',
name: 'Qwen3 Coder Plus',
description: null,
_meta: { contextLimit: 123 },
});
});
it('skips invalid model entries and returns first valid one', () => {
expect(
extractModelInfoFromNewSessionResult({
models: {
currentModelId: 'ok',
availableModels: [
{ name: '', modelId: '' },
{ name: 'Ok', modelId: 'ok', _meta: { contextLimit: null } },
],
},
}),
).toEqual({ name: 'Ok', modelId: 'ok', _meta: { contextLimit: null } });
});
it('falls back to single `model` object', () => {
expect(
extractModelInfoFromNewSessionResult({
model: {
name: 'Single',
modelId: 'single',
_meta: { contextLimit: 999 },
},
}),
).toEqual({
name: 'Single',
modelId: 'single',
_meta: { contextLimit: 999 },
});
});
it('falls back to legacy `modelInfo`', () => {
expect(
extractModelInfoFromNewSessionResult({
modelInfo: { name: 'legacy' },
}),
).toEqual({ name: 'legacy', modelId: 'legacy' });
});
it('returns null when missing', () => {
expect(extractModelInfoFromNewSessionResult({})).toBeNull();
expect(extractModelInfoFromNewSessionResult(null)).toBeNull();
});
});

View File

@@ -0,0 +1,135 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { AcpMeta, ModelInfo } from '../types/acpTypes.js';
const asMeta = (value: unknown): AcpMeta | null | undefined => {
if (value === null) {
return null;
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as AcpMeta;
}
return undefined;
};
const normalizeModelInfo = (value: unknown): ModelInfo | null => {
if (!value || typeof value !== 'object') {
return null;
}
const obj = value as Record<string, unknown>;
const nameRaw = obj['name'];
const modelIdRaw = obj['modelId'];
const descriptionRaw = obj['description'];
const name = typeof nameRaw === 'string' ? nameRaw.trim() : '';
const modelId =
typeof modelIdRaw === 'string' && modelIdRaw.trim().length > 0
? modelIdRaw.trim()
: name;
if (!modelId || modelId.trim().length === 0 || !name) {
return null;
}
const description =
typeof descriptionRaw === 'string' || descriptionRaw === null
? descriptionRaw
: undefined;
const metaFromWire = asMeta(obj['_meta']);
// Back-compat: older implementations used `contextLimit` at the top-level.
const legacyContextLimit = obj['contextLimit'];
const contextLimit =
typeof legacyContextLimit === 'number' || legacyContextLimit === null
? legacyContextLimit
: undefined;
let mergedMeta: AcpMeta | null | undefined = metaFromWire;
if (typeof contextLimit !== 'undefined') {
if (mergedMeta === null) {
mergedMeta = { contextLimit };
} else if (typeof mergedMeta === 'undefined') {
mergedMeta = { contextLimit };
} else {
mergedMeta = { ...mergedMeta, contextLimit };
}
}
return {
modelId,
name,
...(typeof description !== 'undefined' ? { description } : {}),
...(typeof mergedMeta !== 'undefined' ? { _meta: mergedMeta } : {}),
};
};
/**
* Extract model info from ACP `session/new` result.
*
* Per Agent Client Protocol draft schema, NewSessionResponse includes `models`.
* We also accept legacy shapes for compatibility.
*/
export const extractModelInfoFromNewSessionResult = (
result: unknown,
): ModelInfo | null => {
if (!result || typeof result !== 'object') {
return null;
}
const obj = result as Record<string, unknown>;
const models = obj['models'];
// ACP draft: NewSessionResponse.models is a SessionModelState object.
if (models && typeof models === 'object' && !Array.isArray(models)) {
const state = models as Record<string, unknown>;
const availableModels = state['availableModels'];
const currentModelId = state['currentModelId'];
if (Array.isArray(availableModels)) {
const normalizedModels = availableModels
.map(normalizeModelInfo)
.filter((m): m is ModelInfo => Boolean(m));
if (normalizedModels.length > 0) {
if (typeof currentModelId === 'string' && currentModelId.length > 0) {
const selected = normalizedModels.find(
(m) => m.modelId === currentModelId,
);
if (selected) {
return selected;
}
}
return normalizedModels[0];
}
}
}
// Legacy: some implementations returned `models` as a raw array.
if (Array.isArray(models)) {
for (const entry of models) {
const normalized = normalizeModelInfo(entry);
if (normalized) {
return normalized;
}
}
}
// Some implementations may return a single model object.
const model = normalizeModelInfo(obj['model']);
if (model) {
return model;
}
// Legacy: modelInfo on initialize; allow as a fallback.
const legacy = normalizeModelInfo(obj['modelInfo']);
if (legacy) {
return legacy;
}
return null;
};

View File

@@ -53,11 +53,40 @@ export function findLeftGroupOfChatWebview(): vscode.ViewColumn | undefined {
} }
} }
/**
* Wait for a condition to become true, driven by tab-group change events.
* Falls back to a timeout to avoid hanging forever.
*/
function waitForTabGroupsCondition(
condition: () => boolean,
timeout: number = 2000,
): Promise<boolean> {
if (condition()) {
return Promise.resolve(true);
}
return new Promise<boolean>((resolve) => {
const subscription = vscode.window.tabGroups.onDidChangeTabGroups(() => {
if (!condition()) {
return;
}
clearTimeout(timeoutHandle);
subscription.dispose();
resolve(true);
});
const timeoutHandle = setTimeout(() => {
subscription.dispose();
resolve(false);
}, timeout);
});
}
/** /**
* Ensure there is an editor group directly to the left of the Qwen chat webview. * Ensure there is an editor group directly to the left of the Qwen chat webview.
* - If one exists, return its ViewColumn. * - If one exists, return its ViewColumn.
* - If none exists, focus the chat panel and create a new group on its left, * - If none exists, focus the chat panel and create a new group on its left,
* then return the new group's ViewColumn (which equals the chat's previous column). * then return the new group's ViewColumn.
* - If the chat webview cannot be located, returns undefined. * - If the chat webview cannot be located, returns undefined.
*/ */
export async function ensureLeftGroupOfChatWebview(): Promise< export async function ensureLeftGroupOfChatWebview(): Promise<
@@ -87,7 +116,7 @@ export async function ensureLeftGroupOfChatWebview(): Promise<
return undefined; return undefined;
} }
const previousChatColumn = webviewGroup.viewColumn; const initialGroupCount = vscode.window.tabGroups.all.length;
// Make the chat group active by revealing the panel // Make the chat group active by revealing the panel
try { try {
@@ -104,6 +133,22 @@ export async function ensureLeftGroupOfChatWebview(): Promise<
return undefined; return undefined;
} }
// Wait for the new group to actually be created (check that group count increased)
const groupCreated = await waitForTabGroupsCondition(
() => vscode.window.tabGroups.all.length > initialGroupCount,
1000, // 1 second timeout
);
if (!groupCreated) {
// Fallback if group creation didn't complete in time
return vscode.ViewColumn.One;
}
// After creating a new group to the left, the new group takes ViewColumn.One
// and all existing groups shift right. So the new left group is always ViewColumn.One.
// However, to be safe, let's query for it again.
const newLeftGroup = findLeftGroupOfChatWebview();
// Restore focus to chat (optional), so we don't disturb user focus // Restore focus to chat (optional), so we don't disturb user focus
try { try {
await vscode.commands.executeCommand(openChatCommand); await vscode.commands.executeCommand(openChatCommand);
@@ -111,6 +156,7 @@ export async function ensureLeftGroupOfChatWebview(): Promise<
// Ignore // Ignore
} }
// The new left group's column equals the chat's previous column // If we successfully found the new left group, return it
return previousChatColumn; // Otherwise, fallback to ViewColumn.One (the newly created group should be first)
return newLeftGroup ?? vscode.ViewColumn.One;
} }

View File

@@ -27,7 +27,7 @@ import type { TextMessage } from './hooks/message/useMessageHandling.js';
import type { ToolCallData } from './components/messages/toolcalls/ToolCall.js'; import type { ToolCallData } from './components/messages/toolcalls/ToolCall.js';
import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer.js'; import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer.js';
import { ToolCall } from './components/messages/toolcalls/ToolCall.js'; import { ToolCall } from './components/messages/toolcalls/ToolCall.js';
import { hasToolCallOutput } from './components/messages/toolcalls/shared/utils.js'; import { hasToolCallOutput } from './utils/utils.js';
import { EmptyState } from './components/layout/EmptyState.js'; import { EmptyState } from './components/layout/EmptyState.js';
import { Onboarding } from './components/layout/Onboarding.js'; import { Onboarding } from './components/layout/Onboarding.js';
import { type CompletionItem } from '../types/completionItemTypes.js'; import { type CompletionItem } from '../types/completionItemTypes.js';
@@ -45,7 +45,12 @@ import { SessionSelector } from './components/layout/SessionSelector.js';
import { FileIcon, UserIcon } from './components/icons/index.js'; import { FileIcon, UserIcon } from './components/icons/index.js';
import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { PlanEntry } from '../types/chatTypes.js'; import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js';
import type { ModelInfo } from '../types/acpTypes.js';
import {
DEFAULT_TOKEN_LIMIT,
tokenLimit,
} from '@qwen-code/qwen-code-core/src/core/tokenLimits.js';
export const App: React.FC = () => { export const App: React.FC = () => {
const vscode = useVSCode(); const vscode = useVSCode();
@@ -70,6 +75,8 @@ export const App: React.FC = () => {
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]); const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null); const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true); // Track if we're still initializing/loading const [isLoading, setIsLoading] = useState<boolean>(true); // Track if we're still initializing/loading
const [modelInfo, setModelInfo] = useState<ModelInfo | null>(null);
const [usageStats, setUsageStats] = useState<UsageStatsPayload | null>(null);
const messagesEndRef = useRef<HTMLDivElement>( const messagesEndRef = useRef<HTMLDivElement>(
null, null,
) as React.RefObject<HTMLDivElement>; ) as React.RefObject<HTMLDivElement>;
@@ -160,6 +167,48 @@ export const App: React.FC = () => {
const completion = useCompletionTrigger(inputFieldRef, getCompletionItems); const completion = useCompletionTrigger(inputFieldRef, getCompletionItems);
const contextUsage = useMemo(() => {
if (!usageStats && !modelInfo) {
return null;
}
const modelName =
modelInfo?.modelId && typeof modelInfo.modelId === 'string'
? modelInfo.modelId
: modelInfo?.name && typeof modelInfo.name === 'string'
? modelInfo.name
: undefined;
const derivedLimit =
modelName && modelName.length > 0 ? tokenLimit(modelName) : undefined;
const metaLimitRaw = modelInfo?._meta?.['contextLimit'];
const metaLimit =
typeof metaLimitRaw === 'number' || metaLimitRaw === null
? metaLimitRaw
: undefined;
const limit =
usageStats?.tokenLimit ??
metaLimit ??
derivedLimit ??
DEFAULT_TOKEN_LIMIT;
const used = usageStats?.usage?.promptTokens ?? 0;
if (typeof limit !== 'number' || limit <= 0 || used < 0) {
return null;
}
const percentLeft = Math.max(
0,
Math.min(100, Math.round(((limit - used) / limit) * 100)),
);
return {
percentLeft,
usedTokens: used,
tokenLimit: limit,
};
}, [usageStats, modelInfo]);
// Track a lightweight signature of workspace files to detect content changes even when length is unchanged // Track a lightweight signature of workspace files to detect content changes even when length is unchanged
const workspaceFilesSignature = useMemo( const workspaceFilesSignature = useMemo(
() => () =>
@@ -248,6 +297,10 @@ export const App: React.FC = () => {
setInputText, setInputText,
setEditMode, setEditMode,
setIsAuthenticated, setIsAuthenticated,
setUsageStats: (stats) => setUsageStats(stats ?? null),
setModelInfo: (info) => {
setModelInfo(info);
},
}); });
// Auto-scroll handling: keep the view pinned to bottom when new content arrives, // Auto-scroll handling: keep the view pinned to bottom when new content arrives,
@@ -760,6 +813,7 @@ export const App: React.FC = () => {
activeFileName={fileContext.activeFileName} activeFileName={fileContext.activeFileName}
activeSelection={fileContext.activeSelection} activeSelection={fileContext.activeSelection}
skipAutoActiveContext={skipAutoActiveContext} skipAutoActiveContext={skipAutoActiveContext}
contextUsage={contextUsage}
onInputChange={setInputText} onInputChange={setInputText}
onCompositionStart={() => setIsComposing(true)} onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)} onCompositionEnd={() => setIsComposing(false)}

View File

@@ -118,6 +118,20 @@ export class WebViewProvider {
}); });
}); });
this.agentManager.onUsageUpdate((stats) => {
this.sendMessageToWebView({
type: 'usageStats',
data: stats,
});
});
this.agentManager.onModelInfo((info) => {
this.sendMessageToWebView({
type: 'modelInfo',
data: info,
});
});
// Setup end-turn handler from ACP stopReason notifications // Setup end-turn handler from ACP stopReason notifications
this.agentManager.onEndTurn((reason) => { this.agentManager.onEndTurn((reason) => {
// Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere // Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere

View File

@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
interface TooltipProps {
children: React.ReactNode;
content: React.ReactNode;
position?: 'top' | 'bottom' | 'left' | 'right';
}
export const Tooltip: React.FC<TooltipProps> = ({
children,
content,
position = 'top',
}) => (
<div className="relative inline-block">
<div className="group relative">
{children}
<div
className={`
absolute z-50 px-2 py-1 text-xs rounded-md shadow-lg
bg-[var(--app-primary-background)] border border-[var(--app-input-border)]
text-[var(--app-primary-foreground)] whitespace-nowrap
opacity-0 group-hover:opacity-100 transition-opacity duration-150
-translate-x-1/2 left-1/2
${
position === 'top'
? '-translate-y-1 bottom-full mb-1'
: position === 'bottom'
? 'translate-y-1 top-full mt-1'
: position === 'left'
? '-translate-x-full left-0 translate-y-[-50%] top-1/2'
: 'translate-x-0 right-0 translate-y-[-50%] top-1/2'
}
pointer-events-none
`}
>
{content}
<div
className={`
absolute w-2 h-2 bg-[var(--app-primary-background)] border-l border-b border-[var(--app-input-border)]
-rotate-45
${
position === 'top'
? 'top-full left-1/2 -translate-x-1/2 -translate-y-1/2'
: position === 'bottom'
? 'bottom-full left-1/2 -translate-x-1/2 translate-y-1/2'
: position === 'left'
? 'right-full top-1/2 translate-x-1/2 -translate-y-1/2'
: 'left-full top-1/2 -translate-x-1/2 -translate-y-1/2'
}
`}
/>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,88 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Tooltip } from '../Tooltip.js';
interface ContextUsage {
percentLeft: number;
usedTokens: number;
tokenLimit: number;
}
interface ContextIndicatorProps {
contextUsage: ContextUsage | null;
}
export const ContextIndicator: React.FC<ContextIndicatorProps> = ({
contextUsage,
}) => {
if (!contextUsage) {
return null;
}
// Calculate used percentage for the progress indicator
// contextUsage.percentLeft is the percentage remaining, so 100 - percentLeft = percent used
const percentUsed = 100 - contextUsage.percentLeft;
const percentFormatted = Math.max(0, Math.min(100, Math.round(percentUsed)));
const radius = 9;
const circumference = 2 * Math.PI * radius;
// To show the used portion, we need to offset the unused portion
// If 20% is used, we want to show 20% filled, so offset the remaining 80%
const dashOffset = ((100 - percentUsed) / 100) * circumference;
const formatNumber = (value: number) => {
if (value >= 1000) {
return `${(Math.round((value / 1000) * 10) / 10).toFixed(1)}k`;
}
return Math.round(value).toLocaleString();
};
// Create tooltip content with proper formatting
const tooltipContent = (
<div className="flex flex-col gap-1">
<div className="font-medium">
{percentFormatted}% {formatNumber(contextUsage.usedTokens)} /{' '}
{formatNumber(contextUsage.tokenLimit)} context used
</div>
</div>
);
return (
<Tooltip content={tooltipContent} position="top">
<button
className="btn-icon-compact"
aria-label={`${percentFormatted}% • ${formatNumber(contextUsage.usedTokens)} / ${formatNumber(contextUsage.tokenLimit)} context used`}
>
<svg viewBox="0 0 24 24" aria-hidden="true" role="presentation">
<circle
className="context-indicator__track"
cx="12"
cy="12"
r={radius}
fill="none"
stroke="currentColor"
opacity="0.2"
/>
<circle
className="context-indicator__progress"
cx="12"
cy="12"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
style={{
transform: 'rotate(-90deg)',
transformOrigin: '50% 50%',
}}
/>
</svg>
</button>
</Tooltip>
);
};

View File

@@ -21,6 +21,7 @@ import { CompletionMenu } from '../layout/CompletionMenu.js';
import type { CompletionItem } from '../../../types/completionItemTypes.js'; import type { CompletionItem } from '../../../types/completionItemTypes.js';
import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js'; import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js';
import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js'; import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js';
import { ContextIndicator } from './ContextIndicator.js';
interface InputFormProps { interface InputFormProps {
inputText: string; inputText: string;
@@ -36,6 +37,11 @@ interface InputFormProps {
activeSelection: { startLine: number; endLine: number } | null; activeSelection: { startLine: number; endLine: number } | null;
// Whether to auto-load the active editor selection/path into context // Whether to auto-load the active editor selection/path into context
skipAutoActiveContext: boolean; skipAutoActiveContext: boolean;
contextUsage: {
percentLeft: number;
usedTokens: number;
tokenLimit: number;
} | null;
onInputChange: (text: string) => void; onInputChange: (text: string) => void;
onCompositionStart: () => void; onCompositionStart: () => void;
onCompositionEnd: () => void; onCompositionEnd: () => void;
@@ -96,6 +102,7 @@ export const InputForm: React.FC<InputFormProps> = ({
activeFileName, activeFileName,
activeSelection, activeSelection,
skipAutoActiveContext, skipAutoActiveContext,
contextUsage,
onInputChange, onInputChange,
onCompositionStart, onCompositionStart,
onCompositionEnd, onCompositionEnd,
@@ -240,6 +247,9 @@ export const InputForm: React.FC<InputFormProps> = ({
{/* Spacer */} {/* Spacer */}
<div className="flex-1 min-w-0" /> <div className="flex-1 min-w-0" />
{/* Context usage indicator */}
<ContextIndicator contextUsage={contextUsage} />
{/* @yiliang114. closed temporarily */} {/* @yiliang114. closed temporarily */}
{/* Thinking button */} {/* Thinking button */}
{/* <button {/* <button

View File

@@ -3,10 +3,10 @@
* Copyright 2025 Qwen Team * Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
* *
* Execute tool call styles - Enhanced styling with semantic class names * Bash tool call styles - Enhanced styling with semantic class names
*/ */
/* Root container for execute tool call output */ /* Root container for bash tool call output */
.bash-toolcall-card { .bash-toolcall-card {
border: 0.5px solid var(--app-input-border); border: 0.5px solid var(--app-input-border);
border-radius: 5px; border-radius: 5px;
@@ -100,3 +100,9 @@
.bash-toolcall-error-content { .bash-toolcall-error-content {
color: #c74e39; color: #c74e39;
} }
/* Row with copy button */
.bash-toolcall-row-with-copy {
position: relative;
grid-template-columns: max-content 1fr max-content;
}

View File

@@ -9,9 +9,10 @@
import type React from 'react'; import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js'; import type { BaseToolCallProps } from '../shared/types.js';
import { ToolCallContainer } from '../shared/LayoutComponents.js'; import { ToolCallContainer } from '../shared/LayoutComponents.js';
import { safeTitle, groupContent } from '../shared/utils.js'; import { safeTitle, groupContent } from '../../../../utils/utils.js';
import { useVSCode } from '../../../../hooks/useVSCode.js'; import { useVSCode } from '../../../../hooks/useVSCode.js';
import { createAndOpenTempFile } from '../../../../utils/tempFileManager.js'; import { createAndOpenTempFile } from '../../../../utils/diffUtils.js';
import { CopyButton } from '../shared/copyUtils.js';
import './Bash.css'; import './Bash.css';
/** /**
@@ -37,19 +38,14 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Handle click on IN section // Handle click on IN section
const handleInClick = () => { const handleInClick = () => {
createAndOpenTempFile( createAndOpenTempFile(vscode, inputCommand, `bash-input-${toolCallId}`);
vscode.postMessage,
inputCommand,
'bash-input',
'.sh',
);
}; };
// Handle click on OUT section // Handle click on OUT section
const handleOutClick = () => { const handleOutClick = () => {
if (textOutputs.length > 0) { if (textOutputs.length > 0) {
const output = textOutputs.join('\n'); const output = textOutputs.join('\n');
createAndOpenTempFile(vscode.postMessage, output, 'bash-output', '.txt'); createAndOpenTempFile(vscode, output, `bash-output-${toolCallId}`);
} }
}; };
@@ -84,7 +80,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="bash-toolcall-content"> <div className="bash-toolcall-content">
{/* IN row */} {/* IN row */}
<div <div
className="bash-toolcall-row" className="bash-toolcall-row bash-toolcall-row-with-copy group"
onClick={handleInClick} onClick={handleInClick}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
@@ -92,6 +88,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="bash-toolcall-row-content"> <div className="bash-toolcall-row-content">
<pre className="bash-toolcall-pre">{inputCommand}</pre> <pre className="bash-toolcall-pre">{inputCommand}</pre>
</div> </div>
<CopyButton text={inputCommand} />
</div> </div>
{/* ERROR row */} {/* ERROR row */}
@@ -131,7 +128,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="bash-toolcall-content"> <div className="bash-toolcall-content">
{/* IN row */} {/* IN row */}
<div <div
className="bash-toolcall-row" className="bash-toolcall-row bash-toolcall-row-with-copy group"
onClick={handleInClick} onClick={handleInClick}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
@@ -139,6 +136,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="bash-toolcall-row-content"> <div className="bash-toolcall-row-content">
<pre className="bash-toolcall-pre">{inputCommand}</pre> <pre className="bash-toolcall-pre">{inputCommand}</pre>
</div> </div>
<CopyButton text={inputCommand} />
</div> </div>
{/* OUT row */} {/* OUT row */}

View File

@@ -11,7 +11,7 @@ import type { BaseToolCallProps } from '../shared/types.js';
import { import {
groupContent, groupContent,
mapToolStatusToContainerStatus, mapToolStatusToContainerStatus,
} from '../shared/utils.js'; } from '../../../../utils/utils.js';
import { FileLink } from '../../../layout/FileLink.js'; import { FileLink } from '../../../layout/FileLink.js';
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';

View File

@@ -61,11 +61,7 @@
/* Truncated content styling */ /* Truncated content styling */
.execute-toolcall-row-content:not(.execute-toolcall-full) { .execute-toolcall-row-content:not(.execute-toolcall-full) {
max-height: 60px; max-height: 60px;
mask-image: linear-gradient( mask-image: linear-gradient(to bottom, var(--app-primary-background) 40px, transparent 60px);
to bottom,
var(--app-primary-background) 40px,
transparent 60px
);
overflow: hidden; overflow: hidden;
} }
@@ -87,7 +83,6 @@
/* Output content with subtle styling */ /* Output content with subtle styling */
.execute-toolcall-output-subtle { .execute-toolcall-output-subtle {
background-color: var(--app-code-background);
white-space: pre; white-space: pre;
overflow-x: auto; overflow-x: auto;
max-width: 100%; max-width: 100%;
@@ -100,3 +95,9 @@
.execute-toolcall-error-content { .execute-toolcall-error-content {
color: #c74e39; color: #c74e39;
} }
/* Row with copy button */
.execute-toolcall-row-with-copy {
position: relative;
grid-template-columns: max-content 1fr max-content;
}

View File

@@ -8,9 +8,12 @@
import type React from 'react'; import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js'; import type { BaseToolCallProps } from '../shared/types.js';
import { safeTitle, groupContent } from '../shared/utils.js'; import { safeTitle, groupContent } from '../../../../utils/utils.js';
import './Execute.css'; import './Execute.css';
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
import { useVSCode } from '../../../../hooks/useVSCode.js';
import { createAndOpenTempFile } from '../../../../utils/diffUtils.js';
import { CopyButton } from '../shared/copyUtils.js';
export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
label, label,
@@ -48,6 +51,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const commandText = safeTitle( const commandText = safeTitle(
(rawInput as Record<string, unknown>)?.description || title, (rawInput as Record<string, unknown>)?.description || title,
); );
const vscode = useVSCode();
// Group content by type // Group content by type
const { textOutputs, errors } = groupContent(content); const { textOutputs, errors } = groupContent(content);
@@ -61,6 +65,19 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
inputCommand = rawInput; inputCommand = rawInput;
} }
// Handle click on IN section
const handleInClick = () => {
createAndOpenTempFile(vscode, inputCommand, `execute-input-${toolCallId}`);
};
// Handle click on OUT section
const handleOutClick = () => {
if (textOutputs.length > 0) {
const output = textOutputs.join('\n');
createAndOpenTempFile(vscode, output, `execute-output-${toolCallId}`);
}
};
// Map tool status to container status for proper bullet coloring // Map tool status to container status for proper bullet coloring
const containerStatus: const containerStatus:
| 'success' | 'success'
@@ -92,11 +109,16 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="execute-toolcall-card"> <div className="execute-toolcall-card">
<div className="execute-toolcall-content"> <div className="execute-toolcall-content">
{/* IN row */} {/* IN row */}
<div className="execute-toolcall-row"> <div
className="execute-toolcall-row execute-toolcall-row-with-copy group"
onClick={handleInClick}
style={{ cursor: 'pointer' }}
>
<div className="execute-toolcall-label">IN</div> <div className="execute-toolcall-label">IN</div>
<div className="execute-toolcall-row-content"> <div className="execute-toolcall-row-content">
<pre className="execute-toolcall-pre">{inputCommand}</pre> <pre className="execute-toolcall-pre">{inputCommand}</pre>
</div> </div>
<CopyButton text={inputCommand} />
</div> </div>
{/* ERROR row */} {/* ERROR row */}
@@ -135,15 +157,24 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="execute-toolcall-card"> <div className="execute-toolcall-card">
<div className="execute-toolcall-content"> <div className="execute-toolcall-content">
{/* IN row */} {/* IN row */}
<div className="execute-toolcall-row"> <div
className="execute-toolcall-row execute-toolcall-row-with-copy group"
onClick={handleInClick}
style={{ cursor: 'pointer' }}
>
<div className="execute-toolcall-label">IN</div> <div className="execute-toolcall-label">IN</div>
<div className="execute-toolcall-row-content"> <div className="execute-toolcall-row-content">
<pre className="execute-toolcall-pre">{inputCommand}</pre> <pre className="execute-toolcall-pre">{inputCommand}</pre>
</div> </div>
<CopyButton text={inputCommand} />
</div> </div>
{/* OUT row */} {/* OUT row */}
<div className="execute-toolcall-row"> <div
className="execute-toolcall-row"
onClick={handleOutClick}
style={{ cursor: 'pointer' }}
>
<div className="execute-toolcall-label">OUT</div> <div className="execute-toolcall-label">OUT</div>
<div className="execute-toolcall-row-content"> <div className="execute-toolcall-row-content">
<div className="execute-toolcall-output-subtle"> <div className="execute-toolcall-output-subtle">
@@ -164,7 +195,11 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
status={containerStatus} status={containerStatus}
toolCallId={toolCallId} toolCallId={toolCallId}
> >
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"> <div
className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"
onClick={handleInClick}
style={{ cursor: 'pointer' }}
>
<span className="flex-shrink-0 relative top-[-0.1em]"></span> <span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{commandText}</span> <span className="flex-shrink-0 w-full">{commandText}</span>
</div> </div>

View File

@@ -14,7 +14,7 @@ import {
ToolCallRow, ToolCallRow,
LocationsList, LocationsList,
} from './shared/LayoutComponents.js'; } from './shared/LayoutComponents.js';
import { safeTitle, groupContent } from './shared/utils.js'; import { safeTitle, groupContent } from '../../../utils/utils.js';
/** /**
* Generic tool call component that can display any tool call type * Generic tool call component that can display any tool call type

View File

@@ -12,7 +12,7 @@ import type { BaseToolCallProps } from '../shared/types.js';
import { import {
groupContent, groupContent,
mapToolStatusToContainerStatus, mapToolStatusToContainerStatus,
} from '../shared/utils.js'; } from '../../../../utils/utils.js';
import { FileLink } from '../../../layout/FileLink.js'; import { FileLink } from '../../../layout/FileLink.js';
import { useVSCode } from '../../../../hooks/useVSCode.js'; import { useVSCode } from '../../../../hooks/useVSCode.js';
import { handleOpenDiff } from '../../../../utils/diffUtils.js'; import { handleOpenDiff } from '../../../../utils/diffUtils.js';

View File

@@ -13,7 +13,7 @@ import {
safeTitle, safeTitle,
groupContent, groupContent,
mapToolStatusToContainerStatus, mapToolStatusToContainerStatus,
} from '../shared/utils.js'; } from '../../../../utils/utils.js';
/** /**
* Specialized component for Search tool calls * Specialized component for Search tool calls
@@ -195,7 +195,7 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({
isLast={isLast} isLast={isLast}
> >
<div className="flex flex-col"> <div className="flex flex-col">
{textOutputs.map((text, index) => ( {textOutputs.map((text: string, index: number) => (
<div <div
key={index} key={index}
className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1" className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"

View File

@@ -13,7 +13,7 @@ import {
ToolCallCard, ToolCallCard,
ToolCallRow, ToolCallRow,
} from '../shared/LayoutComponents.js'; } from '../shared/LayoutComponents.js';
import { groupContent } from '../shared/utils.js'; import { groupContent } from '../../../../utils/utils.js';
/** /**
* Specialized component for Think tool calls * Specialized component for Think tool calls

View File

@@ -9,7 +9,7 @@
import type React from 'react'; import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js'; import type { BaseToolCallProps } from '../shared/types.js';
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
import { groupContent, safeTitle } from '../shared/utils.js'; import { groupContent, safeTitle } from '../../../../utils/utils.js';
import { CheckboxDisplay } from './CheckboxDisplay.js'; import { CheckboxDisplay } from './CheckboxDisplay.js';
import type { PlanEntry } from '../../../../../types/chatTypes.js'; import type { PlanEntry } from '../../../../../types/chatTypes.js';

View File

@@ -12,7 +12,7 @@ import { ToolCallContainer } from '../shared/LayoutComponents.js';
import { import {
groupContent, groupContent,
mapToolStatusToContainerStatus, mapToolStatusToContainerStatus,
} from '../shared/utils.js'; } from '../../../../utils/utils.js';
import { FileLink } from '../../../layout/FileLink.js'; import { FileLink } from '../../../layout/FileLink.js';
/** /**

View File

@@ -8,7 +8,7 @@
import type React from 'react'; import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js'; import type { BaseToolCallProps } from './shared/types.js';
import { shouldShowToolCall } from './shared/utils.js'; import { shouldShowToolCall } from '../../../utils/utils.js';
import { GenericToolCall } from './GenericToolCall.js'; import { GenericToolCall } from './GenericToolCall.js';
import { ReadToolCall } from './Read/ReadToolCall.js'; import { ReadToolCall } from './Read/ReadToolCall.js';
import { WriteToolCall } from './Write/WriteToolCall.js'; import { WriteToolCall } from './Write/WriteToolCall.js';

View File

@@ -0,0 +1,74 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Shared copy utilities for toolcall components
*/
import type React from 'react';
import { useState } from 'react';
/**
* Handle copy to clipboard
*/
export const handleCopyToClipboard = async (
text: string,
event: React.MouseEvent,
): Promise<void> => {
event.stopPropagation(); // Prevent triggering the row click
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error('Failed to copy text:', err);
}
};
/**
* Copy button component props
*/
interface CopyButtonProps {
text: string;
}
/**
* Shared copy button component with Tailwind styles
* Note: Parent element should have 'group' class for hover effect
*/
export const CopyButton: React.FC<CopyButtonProps> = ({ text }) => {
const [showTooltip, setShowTooltip] = useState(false);
return (
<button
className="col-start-3 bg-transparent border-none px-2 py-1.5 cursor-pointer text-[var(--app-secondary-foreground)] opacity-0 transition-opacity duration-200 ease-out flex items-center justify-center rounded relative group-hover:opacity-70 hover:!opacity-100 hover:bg-[var(--app-input-border)] active:scale-95"
onClick={async (e) => {
await handleCopyToClipboard(text, e);
setShowTooltip(true);
setTimeout(() => setShowTooltip(false), 1000);
}}
title="Copy"
aria-label="Copy to clipboard"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 4V3C4 2.44772 4.44772 2 5 2H13C13.5523 2 14 2.44772 14 3V11C14 11.5523 13.5523 12 13 12H12M3 6H11C11.5523 6 12 6.44772 12 7V13C12 13.5523 11.5523 14 11 14H3C2.44772 14 2 13.5523 2 13V7C2 6.44772 2.44772 6 3 6Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{showTooltip && (
<span className="absolute -top-7 right-0 bg-[var(--app-tool-background)] text-[var(--app-primary-foreground)] px-2 py-1 rounded text-xs whitespace-nowrap border border-[var(--app-input-border)] pointer-events-none">
Copied!
</span>
)}
</button>
);
};

View File

@@ -5,12 +5,14 @@
*/ */
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { BaseMessageHandler } from './BaseMessageHandler.js'; import { BaseMessageHandler } from './BaseMessageHandler.js';
import { getFileName } from '../utils/webviewUtils.js'; import { getFileName } from '../utils/webviewUtils.js';
import { showDiffCommand } from '../../commands/index.js'; import { showDiffCommand } from '../../commands/index.js';
import {
findLeftGroupOfChatWebview,
ensureLeftGroupOfChatWebview,
} from '../../utils/editorGroupUtils.js';
import { ReadonlyFileSystemProvider } from '../../services/readonlyFileSystemProvider.js';
/** /**
* File message handler * File message handler
@@ -396,7 +398,7 @@ export class FileMessageHandler extends BaseMessageHandler {
} }
/** /**
* Create and open temporary file * Create and open temporary readonly file
*/ */
private async handleCreateAndOpenTempFile( private async handleCreateAndOpenTempFile(
data: Record<string, unknown> | undefined, data: Record<string, unknown> | undefined,
@@ -411,26 +413,78 @@ export class FileMessageHandler extends BaseMessageHandler {
try { try {
const content = (data.content as string) || ''; const content = (data.content as string) || '';
const fileName = (data.fileName as string) || 'temp'; const fileName = (data.fileName as string) || 'temp';
const fileExtension = (data.fileExtension as string) || '.txt';
// Create temporary file path // Get readonly file system provider from global singleton
const tempDir = os.tmpdir(); const readonlyProvider = ReadonlyFileSystemProvider.getInstance();
const tempFileName = `${fileName}-${Date.now()}${fileExtension}`; if (!readonlyProvider) {
const tempFilePath = path.join(tempDir, tempFileName); const errorMessage = 'Readonly file system provider not initialized';
console.error('[FileMessageHandler]', errorMessage);
this.sendToWebView({
type: 'error',
data: { message: errorMessage },
});
return;
}
// Write content to temporary file // Create readonly URI (without timestamp to ensure consistency)
await fs.promises.writeFile(tempFilePath, content, 'utf8'); const uri = readonlyProvider.createUri(fileName, content);
readonlyProvider.setContent(uri, content);
// Open the temporary file in VS Code // If the document already has an open tab, focus that same tab instead of opening a new one.
const uri = vscode.Uri.file(tempFilePath); let foundExistingTab = false;
await vscode.window.showTextDocument(uri, { let existingViewColumn: vscode.ViewColumn | undefined;
for (const tabGroup of vscode.window.tabGroups.all) {
for (const tab of tabGroup.tabs) {
const input = tab.input as { uri?: vscode.Uri } | undefined;
if (input?.uri && input.uri.toString() === uri.toString()) {
foundExistingTab = true;
existingViewColumn = tabGroup.viewColumn;
break;
}
}
if (foundExistingTab) {
break;
}
}
if (foundExistingTab) {
const document = await vscode.workspace.openTextDocument(uri);
const showOptions: vscode.TextDocumentShowOptions = {
preview: false,
preserveFocus: false,
};
if (existingViewColumn !== undefined) {
showOptions.viewColumn = existingViewColumn;
}
await vscode.window.showTextDocument(document, showOptions);
console.log(
'[FileMessageHandler] Focused on existing readonly file:',
uri.toString(),
'in viewColumn:',
existingViewColumn,
);
return;
}
// Find or ensure left group of chat webview
let targetViewColumn = findLeftGroupOfChatWebview();
if (targetViewColumn === undefined) {
targetViewColumn = await ensureLeftGroupOfChatWebview();
}
// Open as readonly document in the left group and focus it (single click should be enough)
const document = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(document, {
viewColumn: targetViewColumn ?? vscode.ViewColumn.Beside,
preview: false, preview: false,
preserveFocus: false, preserveFocus: false,
}); });
console.log( console.log(
'[FileMessageHandler] Created and opened temporary file:', '[FileMessageHandler] Created and opened readonly file:',
tempFilePath, uri.toString(),
'in viewColumn:',
targetViewColumn ?? 'Beside',
); );
} catch (error) { } catch (error) {
console.error( console.error(

View File

@@ -11,9 +11,13 @@ import type {
PermissionOption, PermissionOption,
ToolCall as PermissionToolCall, ToolCall as PermissionToolCall,
} from '../components/PermissionDrawer/PermissionRequest.js'; } from '../components/PermissionDrawer/PermissionRequest.js';
import type { ToolCallUpdate } from '../../types/chatTypes.js'; import type {
ToolCallUpdate,
UsageStatsPayload,
} from '../../types/chatTypes.js';
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
import type { PlanEntry } from '../../types/chatTypes.js'; import type { PlanEntry } from '../../types/chatTypes.js';
import type { ModelInfo } from '../../types/acpTypes.js';
const FORCE_CLEAR_STREAM_END_REASONS = new Set([ const FORCE_CLEAR_STREAM_END_REASONS = new Set([
'user_cancelled', 'user_cancelled',
@@ -119,6 +123,10 @@ interface UseWebViewMessagesProps {
setEditMode?: (mode: ApprovalModeValue) => void; setEditMode?: (mode: ApprovalModeValue) => void;
// Authentication state setter // Authentication state setter
setIsAuthenticated?: (authenticated: boolean | null) => void; setIsAuthenticated?: (authenticated: boolean | null) => void;
// Usage stats setter
setUsageStats?: (stats: UsageStatsPayload | undefined) => void;
// Model info setter
setModelInfo?: (info: ModelInfo | null) => void;
} }
/** /**
@@ -137,12 +145,15 @@ export const useWebViewMessages = ({
setInputText, setInputText,
setEditMode, setEditMode,
setIsAuthenticated, setIsAuthenticated,
setUsageStats,
setModelInfo,
}: UseWebViewMessagesProps) => { }: UseWebViewMessagesProps) => {
// VS Code API for posting messages back to the extension host // VS Code API for posting messages back to the extension host
const vscode = useVSCode(); const vscode = useVSCode();
// Track active long-running tool calls (execute/bash/command) so we can // Track active long-running tool calls (execute/bash/command) so we can
// keep the bottom "waiting" message visible until all of them complete. // keep the bottom "waiting" message visible until all of them complete.
const activeExecToolCallsRef = useRef<Set<string>>(new Set()); const activeExecToolCallsRef = useRef<Set<string>>(new Set());
const modelInfoRef = useRef<ModelInfo | null>(null);
// Use ref to store callbacks to avoid useEffect dependency issues // Use ref to store callbacks to avoid useEffect dependency issues
const handlersRef = useRef({ const handlersRef = useRef({
sessionManagement, sessionManagement,
@@ -153,6 +164,8 @@ export const useWebViewMessages = ({
setPlanEntries, setPlanEntries,
handlePermissionRequest, handlePermissionRequest,
setIsAuthenticated, setIsAuthenticated,
setUsageStats,
setModelInfo,
}); });
// Track last "Updated Plan" snapshot toolcall to support merge/dedupe // Track last "Updated Plan" snapshot toolcall to support merge/dedupe
@@ -198,6 +211,8 @@ export const useWebViewMessages = ({
setPlanEntries, setPlanEntries,
handlePermissionRequest, handlePermissionRequest,
setIsAuthenticated, setIsAuthenticated,
setUsageStats,
setModelInfo,
}; };
}); });
@@ -230,6 +245,42 @@ export const useWebViewMessages = ({
break; break;
} }
case 'usageStats': {
const stats = message.data as UsageStatsPayload | undefined;
handlers.setUsageStats?.(stats);
break;
}
case 'modelInfo': {
const info = message.data as Partial<ModelInfo> | undefined;
if (
info &&
typeof info.name === 'string' &&
info.name.trim().length > 0
) {
const modelId =
typeof info.modelId === 'string' && info.modelId.trim().length > 0
? info.modelId.trim()
: info.name.trim();
const normalized: ModelInfo = {
modelId,
name: info.name.trim(),
...(typeof info.description !== 'undefined'
? { description: info.description ?? null }
: {}),
...(typeof info._meta !== 'undefined'
? { _meta: info._meta }
: {}),
};
modelInfoRef.current = normalized;
handlers.setModelInfo?.(normalized);
} else {
modelInfoRef.current = null;
handlers.setModelInfo?.(null);
}
break;
}
case 'loginSuccess': { case 'loginSuccess': {
// Clear loading state and show a short assistant notice // Clear loading state and show a short assistant notice
handlers.messageHandling.clearWaitingForResponse(); handlers.messageHandling.clearWaitingForResponse();

View File

@@ -151,6 +151,28 @@
fill: var(--app-qwen-ivory); fill: var(--app-qwen-ivory);
} }
.context-indicator {
@apply inline-flex items-center gap-1 px-1 py-0.5 rounded-small text-[0.8em] select-none;
color: var(--app-secondary-foreground);
}
.context-indicator svg {
width: 20px;
height: 20px;
}
.context-indicator__track,
.context-indicator__progress {
fill: none;
stroke-width: 2.5;
}
.context-indicator__track {
stroke: var(--app-secondary-foreground);
opacity: 0.35;
}
.context-indicator__progress {
stroke: var(--app-secondary-foreground);
stroke-linecap: round;
}
.composer-overlay { .composer-overlay {
@apply absolute inset-0 rounded-large z-0; @apply absolute inset-0 rounded-large z-0;
background: var(--app-input-background); background: var(--app-input-background);

View File

@@ -28,3 +28,22 @@ export const handleOpenDiff = (
}); });
} }
}; };
/**
* Creates a temporary readonly file with the given content and opens it in VS Code
* @param content The content to write to the temporary file
* @param fileName File name (will be auto-generated with timestamp)
*/
export const createAndOpenTempFile = (
vscode: VSCodeAPI,
content: string,
fileName: string = 'temp',
): void => {
vscode.postMessage({
type: 'createAndOpenTempFile',
data: {
content,
fileName,
},
});
};

View File

@@ -1,33 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Temporary file manager for creating and opening temporary files in webview
*/
/**
* Creates a temporary file with the given content and opens it in VS Code
* @param content The content to write to the temporary file
* @param fileName Optional file name (without extension)
* @param fileExtension Optional file extension (defaults to .txt)
*/
export async function createAndOpenTempFile(
postMessage: (message: {
type: string;
data: Record<string, unknown>;
}) => void,
content: string,
fileName: string = 'temp',
fileExtension: string = '.txt',
): Promise<void> {
// Send message to VS Code extension to create and open temp file
postMessage({
type: 'createAndOpenTempFile',
data: {
content,
fileName,
fileExtension,
},
});
}

View File

@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Unit tests for toolcall utility functions
*/
import { describe, it, expect } from 'vitest';
import { extractCommandOutput, formatValue } from './utils.js';
describe('extractCommandOutput', () => {
it('should extract output from JSON format', () => {
const input = JSON.stringify({ output: 'Hello World' });
expect(extractCommandOutput(input)).toBe('Hello World');
});
it('should handle uppercase Output in JSON', () => {
const input = JSON.stringify({ Output: 'Test Output' });
expect(extractCommandOutput(input)).toBe('Test Output');
});
it('should extract output from structured text format', () => {
const input = `Command: lsof -i :5173
Directory: (root)
Output: COMMAND PID USER FD TYPE
node 59117 jinjing 17u IPv6
Error: (none)
Exit Code: 0`;
const output = extractCommandOutput(input);
expect(output).toContain('COMMAND PID USER');
expect(output).toContain('node 59117 jinjing');
expect(output).not.toContain('Command:');
expect(output).not.toContain('Error:');
});
it('should handle multiline output correctly', () => {
const input = `Command: ps aux
Directory: /home/user
Output: USER PID %CPU %MEM
root 1 0.0 0.1
user 1234 1.5 2.3
Error: (none)
Exit Code: 0`;
const output = extractCommandOutput(input);
expect(output).toContain('USER PID %CPU %MEM');
expect(output).toContain('root 1');
expect(output).toContain('user 1234');
});
it('should skip (none) output', () => {
const input = `Command: test
Output: (none)
Error: (none)`;
const output = extractCommandOutput(input);
expect(output).toBe(input); // Should return original if output is (none)
});
it('should return original text if no structured format found', () => {
const input = 'Just some random text';
expect(extractCommandOutput(input)).toBe(input);
});
it('should handle empty output gracefully', () => {
const input = `Command: test
Output:
Error: (none)`;
const output = extractCommandOutput(input);
// Should return original since output is empty
expect(output).toBe(input);
});
it('should extract from regex match when Output: is present', () => {
const input = `Some text before
Output: This is the output
Error: Some error`;
expect(extractCommandOutput(input)).toBe('This is the output');
});
it('should handle JSON objects in output field', () => {
const input = JSON.stringify({
output: { key: 'value', nested: { data: 'test' } },
});
const output = extractCommandOutput(input);
expect(output).toContain('"key"');
expect(output).toContain('"value"');
});
});
describe('formatValue', () => {
it('should return empty string for null or undefined', () => {
expect(formatValue(null)).toBe('');
expect(formatValue(undefined)).toBe('');
});
it('should extract output from string using extractCommandOutput', () => {
const input = `Command: test
Output: Hello World
Error: (none)`;
const output = formatValue(input);
expect(output).toContain('Hello World');
});
it('should handle Error objects', () => {
const error = new Error('Test error message');
expect(formatValue(error)).toBe('Test error message');
});
it('should handle error-like objects', () => {
const errorObj = { message: 'Custom error', stack: 'stack trace' };
expect(formatValue(errorObj)).toBe('Custom error');
});
it('should stringify objects', () => {
const obj = { key: 'value', number: 42 };
const output = formatValue(obj);
expect(output).toContain('"key"');
expect(output).toContain('"value"');
expect(output).toContain('42');
});
it('should convert primitives to string', () => {
expect(formatValue(123)).toBe('123');
expect(formatValue(true)).toBe('true');
expect(formatValue(false)).toBe('false');
});
});

View File

@@ -9,8 +9,102 @@
import type { import type {
ToolCallContent, ToolCallContent,
GroupedContent, GroupedContent,
ToolCallData,
ToolCallStatus, ToolCallStatus,
} from './types.js'; } from '../components/messages/toolcalls/shared/types.js';
/**
* Extract output from command execution result text
* Handles both JSON format and structured text format
*
* Example structured text:
* ```
* Command: lsof -i :5173
* Directory: (root)
* Output: COMMAND PID USER...
* Error: (none)
* Exit Code: 0
* ```
*/
export const extractCommandOutput = (text: string): string => {
// First try: Parse as JSON and extract output field
try {
const parsed = JSON.parse(text) as { output?: unknown; Output?: unknown };
const output = parsed.output ?? parsed.Output;
if (output !== undefined && output !== null) {
return typeof output === 'string'
? output
: JSON.stringify(output, null, 2);
}
} catch (_error) {
// Not JSON, continue with text parsing
}
// Second try: Extract from structured text format
// Look for "Output: " followed by content until "Error: " or end of string
// Only match if there's actual content after "Output:" (not just whitespace)
// Avoid treating the next line (e.g. "Error: ...") as output when the Output line is empty.
// Intentionally do not allow `\s*` here since it would consume newlines.
const outputMatch = text.match(/Output:[ \t]*(.+?)(?=\nError:|$)/i);
if (outputMatch && outputMatch[1]) {
const output = outputMatch[1].trim();
// Only return if there's meaningful content (not just "(none)" or empty)
if (output && output !== '(none)' && output.length > 0) {
return output;
}
}
// Third try: Check if text starts with structured format (Command:, Directory:, etc.)
// If so, try to extract everything between first line and "Error:" or "Exit Code:"
if (text.match(/^Command:/)) {
const lines = text.split('\n');
const outputLines: string[] = [];
let inOutput = false;
for (const line of lines) {
// Stop at metadata lines
if (
line.startsWith('Error:') ||
line.startsWith('Exit Code:') ||
line.startsWith('Signal:') ||
line.startsWith('Background PIDs:') ||
line.startsWith('Process Group PGID:')
) {
break;
}
// Skip header lines
if (line.startsWith('Command:') || line.startsWith('Directory:')) {
continue;
}
// Start collecting after "Output:" label
if (line.startsWith('Output:')) {
inOutput = true;
const content = line.substring('Output:'.length).trim();
if (content && content !== '(none)') {
outputLines.push(content);
}
continue;
}
// Collect output lines
if (
inOutput ||
(!line.startsWith('Command:') && !line.startsWith('Directory:'))
) {
outputLines.push(line);
}
}
if (outputLines.length > 0) {
const result = outputLines.join('\n').trim();
if (result && result !== '(none)') {
return result;
}
}
}
// Fallback: Return original text
return text;
};
/** /**
* Format any value to a string for display * Format any value to a string for display
@@ -20,13 +114,8 @@ export const formatValue = (value: unknown): string => {
return ''; return '';
} }
if (typeof value === 'string') { if (typeof value === 'string') {
// TODO: Trying to take out the Output part from the string // Extract command output from structured text
try { return extractCommandOutput(value);
value = (JSON.parse(value) as { output?: unknown }).output ?? value;
} catch (_error) {
// ignore JSON parse errors
}
return value as string;
} }
// Handle Error objects specially // Handle Error objects specially
if (value instanceof Error) { if (value instanceof Error) {
@@ -72,9 +161,7 @@ export const shouldShowToolCall = (kind: string): boolean =>
* Check if a tool call has actual output to display * Check if a tool call has actual output to display
* Returns false for tool calls that completed successfully but have no visible output * Returns false for tool calls that completed successfully but have no visible output
*/ */
export const hasToolCallOutput = ( export const hasToolCallOutput = (toolCall: ToolCallData): boolean => {
toolCall: import('./types.js').ToolCallData,
): boolean => {
// Always show failed tool calls (even without content) // Always show failed tool calls (even without content)
if (toolCall.status === 'failed') { if (toolCall.status === 'failed') {
return true; return true;

View File

@@ -33,7 +33,7 @@ interface CheckResult {
*/ */
async function loadTranslationsFile( async function loadTranslationsFile(
filePath: string, filePath: string,
): Promise<Record<string, string>> { ): Promise<Record<string, string | string[]>> {
try { try {
// Dynamic import for ES modules // Dynamic import for ES modules
const module = await import(filePath); const module = await import(filePath);
@@ -118,8 +118,8 @@ async function extractUsedKeys(sourceDir: string): Promise<Set<string>> {
try { try {
const content = fs.readFileSync(filePath, 'utf-8'); const content = fs.readFileSync(filePath, 'utf-8');
// Find all t( calls // Find all t( or ta( calls
const tCallRegex = /t\s*\(/g; const tCallRegex = /\bta?\s*\(/g;
let match; let match;
while ((match = tCallRegex.exec(content)) !== null) { while ((match = tCallRegex.exec(content)) !== null) {
const startPos = match.index + match[0].length; const startPos = match.index + match[0].length;
@@ -153,11 +153,16 @@ async function extractUsedKeys(sourceDir: string): Promise<Set<string>> {
* Check key-value consistency in en.js * Check key-value consistency in en.js
*/ */
function checkKeyValueConsistency( function checkKeyValueConsistency(
enTranslations: Record<string, string>, enTranslations: Record<string, string | string[]>,
): string[] { ): string[] {
const errors: string[] = []; const errors: string[] = [];
for (const [key, value] of Object.entries(enTranslations)) { for (const [key, value] of Object.entries(enTranslations)) {
// Skip array values as they don't follow the key=value rule (e.g., WITTY_LOADING_PHRASES)
if (Array.isArray(value)) {
continue;
}
if (key !== value) { if (key !== value) {
errors.push(`Key-value mismatch: "${key}" !== "${value}"`); errors.push(`Key-value mismatch: "${key}" !== "${value}"`);
} }
@@ -170,8 +175,8 @@ function checkKeyValueConsistency(
* Check if en.js and zh.js have matching keys * Check if en.js and zh.js have matching keys
*/ */
function checkKeyMatching( function checkKeyMatching(
enTranslations: Record<string, string>, enTranslations: Record<string, string | string[]>,
zhTranslations: Record<string, string>, zhTranslations: Record<string, string | string[]>,
): string[] { ): string[] {
const errors: string[] = []; const errors: string[] = [];
const enKeys = new Set(Object.keys(enTranslations)); const enKeys = new Set(Object.keys(enTranslations));
@@ -301,8 +306,8 @@ async function checkI18n(): Promise<CheckResult> {
const zhPath = path.join(localesDir, 'zh.js'); const zhPath = path.join(localesDir, 'zh.js');
// Load translation files // Load translation files
let enTranslations: Record<string, string>; let enTranslations: Record<string, string | string[]>;
let zhTranslations: Record<string, string>; let zhTranslations: Record<string, string | string[]>;
try { try {
enTranslations = await loadTranslationsFile(enPath); enTranslations = await loadTranslationsFile(enPath);

View File

@@ -0,0 +1,61 @@
{
"generatedAt": "2025-12-24T09:15:59.125Z",
"keys": [
" - en-US: English",
" - zh-CN: Simplified Chinese",
"A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?",
"Apply to current session only (temporary)",
"Approval mode changed to: {{mode}}",
"Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})",
"Auto-edit mode - Automatically approve file edits",
"Available approval modes:",
"Chat history is already compressed.",
"Clearing terminal and resetting chat.",
"Clearing terminal.",
"Conversation checkpoint '{{tag}}' has been deleted.",
"Conversation checkpoint saved with tag: {{tag}}.",
"Conversation shared to {{filePath}}",
"Current approval mode: {{mode}}",
"Default mode - Require approval for file edits or shell commands",
"Delete a conversation checkpoint. Usage: /chat delete <tag>",
"Enable Prompt Completion",
"Error sharing conversation: {{error}}",
"Error: No checkpoint found with tag '{{tag}}'.",
"Failed to change approval mode: {{error}}",
"Failed to login. Message: {{message}}",
"Failed to save approval mode: {{error}}",
"Invalid file format. Only .md and .json are supported.",
"Invalid language. Available: en-US, zh-CN",
"List of saved conversations:",
"List saved conversation checkpoints",
"Manage conversation history.",
"Missing tag. Usage: /chat delete <tag>",
"Missing tag. Usage: /chat resume <tag>",
"Missing tag. Usage: /chat save <tag>",
"No chat client available to save conversation.",
"No chat client available to share conversation.",
"No conversation found to save.",
"No conversation found to share.",
"No saved checkpoint found with tag: {{tag}}.",
"No saved conversation checkpoints found.",
"Note: Newest last, oldest first",
"OpenAI API key is required to use OpenAI authentication.",
"Persist for this project/workspace",
"Persist for this user on this machine",
"Plan mode - Analyze only, do not modify files or execute commands",
"Qwen OAuth authentication cancelled.",
"Qwen OAuth authentication timed out. Please try again.",
"Resume a conversation from a checkpoint. Usage: /chat resume <tag>",
"Save the current conversation as a checkpoint. Usage: /chat save <tag>",
"Scope subcommands do not accept additional arguments.",
"Set UI language to English (en-US)",
"Set UI language to Simplified Chinese (zh-CN)",
"Settings service is not available; unable to persist the approval mode.",
"Share the current conversation to a markdown or json file. Usage: /chat share <file>",
"Usage: /approval-mode <mode> [--session|--user|--project]",
"Usage: /language ui [zh-CN|en-US]",
"YOLO mode - Automatically approve all tools",
"clear the screen and conversation history"
],
"count": 55
}

View File

@@ -37,7 +37,8 @@ if (!versionType) {
run(`npm version ${versionType} --no-git-tag-version --allow-same-version`); run(`npm version ${versionType} --no-git-tag-version --allow-same-version`);
// 3. Get all workspaces and filter out the one we don't want to version. // 3. Get all workspaces and filter out the one we don't want to version.
const workspacesToExclude = []; // We intend to maintain sdk version independently.
const workspacesToExclude = ['@qwen-code/sdk'];
let lsOutput; let lsOutput;
try { try {
lsOutput = JSON.parse( lsOutput = JSON.parse(