mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-25 02:59:13 +00:00
Compare commits
26 Commits
feat/skill
...
fix-langua
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
101bd5f9b3 | ||
|
|
61c626b618 | ||
|
|
a28278e950 | ||
|
|
a8f7bab544 | ||
|
|
4ca62ba836 | ||
|
|
398a1044ce | ||
|
|
642dda0315 | ||
|
|
bbbdeb280d | ||
|
|
0d43ddee2a | ||
|
|
50e03f2dd6 | ||
|
|
f440ff2f7f | ||
|
|
9a6b0abc37 | ||
|
|
f07259a7c9 | ||
|
|
4d9f25e9fe | ||
|
|
80bb2890df | ||
|
|
abd9ee2a7b | ||
|
|
b8df689e31 | ||
|
|
15efeb0107 | ||
|
|
e610578ecc | ||
|
|
235159216e | ||
|
|
93b30cca29 | ||
|
|
2f0fa267c8 | ||
|
|
fa6ae0a324 | ||
|
|
387be44866 | ||
|
|
51b82771da | ||
|
|
629cd14fad |
110
CONTRIBUTING.md
110
CONTRIBUTING.md
@@ -2,27 +2,6 @@
|
||||
|
||||
We would love to accept your patches and contributions to this project.
|
||||
|
||||
## Before you begin
|
||||
|
||||
### Sign our Contributor License Agreement
|
||||
|
||||
Contributions to this project must be accompanied by a
|
||||
[Contributor License Agreement](https://cla.developers.google.com/about) (CLA).
|
||||
You (or your employer) retain the copyright to your contribution; this simply
|
||||
gives us permission to use and redistribute your contributions as part of the
|
||||
project.
|
||||
|
||||
If you or your current employer have already signed the Google CLA (even if it
|
||||
was for a different project), you probably don't need to do it again.
|
||||
|
||||
Visit <https://cla.developers.google.com/> to see your current agreements or to
|
||||
sign a new one.
|
||||
|
||||
### Review our Community Guidelines
|
||||
|
||||
This project follows [Google's Open Source Community
|
||||
Guidelines](https://opensource.google/conduct/).
|
||||
|
||||
## Contribution Process
|
||||
|
||||
### Code Reviews
|
||||
@@ -74,12 +53,6 @@ Your PR should have a clear, descriptive title and a detailed description of the
|
||||
|
||||
In the PR description, explain the "why" behind your changes and link to the relevant issue (e.g., `Fixes #123`).
|
||||
|
||||
## Forking
|
||||
|
||||
If you are forking the repository you will be able to run the Build, Test and Integration test workflows. However in order to make the integration tests run you'll need to add a [GitHub Repository Secret](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions#creating-secrets-for-a-repository) with a value of `GEMINI_API_KEY` and set that to a valid API key that you have available. Your key and secret are private to your repo; no one without access can see your key and you cannot see any secrets related to this repo.
|
||||
|
||||
Additionally you will need to click on the `Actions` tab and enable workflows for your repository, you'll find it's the large blue button in the center of the screen.
|
||||
|
||||
## Development Setup and Workflow
|
||||
|
||||
This section guides contributors on how to build, modify, and understand the development setup of this project.
|
||||
@@ -98,8 +71,8 @@ This section guides contributors on how to build, modify, and understand the dev
|
||||
To clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/google-gemini/gemini-cli.git # Or your fork's URL
|
||||
cd gemini-cli
|
||||
git clone https://github.com/QwenLM/qwen-code.git # Or your fork's URL
|
||||
cd qwen-code
|
||||
```
|
||||
|
||||
To install dependencies defined in `package.json` as well as root dependencies:
|
||||
@@ -118,9 +91,9 @@ This command typically compiles TypeScript to JavaScript, bundles assets, and pr
|
||||
|
||||
### Enabling Sandboxing
|
||||
|
||||
[Sandboxing](#sandboxing) is highly recommended and requires, at a minimum, setting `GEMINI_SANDBOX=true` in your `~/.env` and ensuring a sandboxing provider (e.g. `macOS Seatbelt`, `docker`, or `podman`) is available. See [Sandboxing](#sandboxing) for details.
|
||||
[Sandboxing](#sandboxing) is highly recommended and requires, at a minimum, setting `QWEN_SANDBOX=true` in your `~/.env` and ensuring a sandboxing provider (e.g. `macOS Seatbelt`, `docker`, or `podman`) is available. See [Sandboxing](#sandboxing) for details.
|
||||
|
||||
To build both the `gemini` CLI utility and the sandbox container, run `build:all` from the root directory:
|
||||
To build both the `qwen-code` CLI utility and the sandbox container, run `build:all` from the root directory:
|
||||
|
||||
```bash
|
||||
npm run build:all
|
||||
@@ -130,13 +103,13 @@ To skip building the sandbox container, you can use `npm run build` instead.
|
||||
|
||||
### Running
|
||||
|
||||
To start the Gemini CLI from the source code (after building), run the following command from the root directory:
|
||||
To start the Qwen Code application from the source code (after building), run the following command from the root directory:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
If you'd like to run the source build outside of the gemini-cli folder, you can utilize `npm link path/to/gemini-cli/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) or `alias gemini="node path/to/gemini-cli/packages/cli"` to run with `gemini`
|
||||
If you'd like to run the source build outside of the qwen-code folder, you can utilize `npm link path/to/qwen-code/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) to run with `qwen-code`
|
||||
|
||||
### Running Tests
|
||||
|
||||
@@ -154,7 +127,7 @@ This will run tests located in the `packages/core` and `packages/cli` directorie
|
||||
|
||||
#### Integration Tests
|
||||
|
||||
The integration tests are designed to validate the end-to-end functionality of the Gemini CLI. They are not run as part of the default `npm run test` command.
|
||||
The integration tests are designed to validate the end-to-end functionality of Qwen Code. They are not run as part of the default `npm run test` command.
|
||||
|
||||
To run the integration tests, use the following command:
|
||||
|
||||
@@ -209,19 +182,61 @@ npm run lint
|
||||
### Coding Conventions
|
||||
|
||||
- Please adhere to the coding style, patterns, and conventions used throughout the existing codebase.
|
||||
- Consult [QWEN.md](https://github.com/QwenLM/qwen-code/blob/main/QWEN.md) (typically found in the project root) for specific instructions related to AI-assisted development, including conventions for React, comments, and Git usage.
|
||||
- **Imports:** Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages.
|
||||
|
||||
### Project Structure
|
||||
|
||||
- `packages/`: Contains the individual sub-packages of the project.
|
||||
- `cli/`: The command-line interface.
|
||||
- `core/`: The core backend logic for the Gemini CLI.
|
||||
- `core/`: The core backend logic for Qwen Code.
|
||||
- `docs/`: Contains all project documentation.
|
||||
- `scripts/`: Utility scripts for building, testing, and development tasks.
|
||||
|
||||
For more detailed architecture, see `docs/architecture.md`.
|
||||
|
||||
## Documentation Development
|
||||
|
||||
This section describes how to develop and preview the documentation locally.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Ensure you have Node.js (version 18+) installed
|
||||
2. Have npm or yarn available
|
||||
|
||||
### Setup Documentation Site Locally
|
||||
|
||||
To work on the documentation and preview changes locally:
|
||||
|
||||
1. Navigate to the `docs-site` directory:
|
||||
|
||||
```bash
|
||||
cd docs-site
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Link the documentation content from the main `docs` directory:
|
||||
|
||||
```bash
|
||||
npm run link
|
||||
```
|
||||
|
||||
This creates a symbolic link from `../docs` to `content` in the docs-site project, allowing the documentation content to be served by the Next.js site.
|
||||
|
||||
4. Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. Open [http://localhost:3000](http://localhost:3000) in your browser to see the documentation site with live updates as you make changes.
|
||||
|
||||
Any changes made to the documentation files in the main `docs` directory will be reflected immediately in the documentation site.
|
||||
|
||||
## Debugging
|
||||
|
||||
### VS Code:
|
||||
@@ -231,7 +246,7 @@ For more detailed architecture, see `docs/architecture.md`.
|
||||
```bash
|
||||
npm run debug
|
||||
```
|
||||
This command runs `node --inspect-brk dist/gemini.js` within the `packages/cli` directory, pausing execution until a debugger attaches. You can then open `chrome://inspect` in your Chrome browser to connect to the debugger.
|
||||
This command runs `node --inspect-brk dist/index.js` within the `packages/cli` directory, pausing execution until a debugger attaches. You can then open `chrome://inspect` in your Chrome browser to connect to the debugger.
|
||||
2. In VS Code, use the "Attach" launch configuration (found in `.vscode/launch.json`).
|
||||
|
||||
Alternatively, you can use the "Launch Program" configuration in VS Code if you prefer to launch the currently open file directly, but 'F5' is generally recommended.
|
||||
@@ -239,16 +254,16 @@ Alternatively, you can use the "Launch Program" configuration in VS Code if you
|
||||
To hit a breakpoint inside the sandbox container run:
|
||||
|
||||
```bash
|
||||
DEBUG=1 gemini
|
||||
DEBUG=1 qwen-code
|
||||
```
|
||||
|
||||
**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings.
|
||||
**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect qwen-code due to automatic exclusion. Use `.qwen-code/.env` files for qwen-code specific debug settings.
|
||||
|
||||
### React DevTools
|
||||
|
||||
To debug the CLI's React-based UI, you can use React DevTools. Ink, the library used for the CLI's interface, is compatible with React DevTools version 4.x.
|
||||
|
||||
1. **Start the Gemini CLI in development mode:**
|
||||
1. **Start the Qwen Code application in development mode:**
|
||||
|
||||
```bash
|
||||
DEV=true npm start
|
||||
@@ -270,23 +285,10 @@ To debug the CLI's React-based UI, you can use React DevTools. Ink, the library
|
||||
```
|
||||
|
||||
Your running CLI application should then connect to React DevTools.
|
||||

|
||||
|
||||
## Sandboxing
|
||||
|
||||
### macOS Seatbelt
|
||||
|
||||
On macOS, `qwen` uses Seatbelt (`sandbox-exec`) under a `permissive-open` profile (see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) that restricts writes to the project folder but otherwise allows all other operations and outbound network traffic ("open") by default. You can switch to a `restrictive-closed` profile (see `packages/cli/src/utils/sandbox-macos-restrictive-closed.sb`) that declines all operations and outbound network traffic ("closed") by default by setting `SEATBELT_PROFILE=restrictive-closed` in your environment or `.env` file. Available built-in profiles are `{permissive,restrictive}-{open,closed,proxied}` (see below for proxied networking). You can also switch to a custom profile `SEATBELT_PROFILE=<profile>` if you also create a file `.qwen/sandbox-macos-<profile>.sb` under your project settings directory `.qwen`.
|
||||
|
||||
### Container-based Sandboxing (All Platforms)
|
||||
|
||||
For stronger container-based sandboxing on macOS or other platforms, you can set `GEMINI_SANDBOX=true|docker|podman|<command>` in your environment or `.env` file. The specified command (or if `true` then either `docker` or `podman`) must be installed on the host machine. Once enabled, `npm run build:all` will build a minimal container ("sandbox") image and `npm start` will launch inside a fresh instance of that container. The first build can take 20-30s (mostly due to downloading of the base image) but after that both build and start overhead should be minimal. Default builds (`npm run build`) will not rebuild the sandbox.
|
||||
|
||||
Container-based sandboxing mounts the project directory (and system temp directory) with read-write access and is started/stopped/removed automatically as you start/stop Gemini CLI. Files created within the sandbox should be automatically mapped to your user/group on host machine. You can easily specify additional mounts, ports, or environment variables by setting `SANDBOX_{MOUNTS,PORTS,ENV}` as needed. You can also fully customize the sandbox for your projects by creating the files `.qwen/sandbox.Dockerfile` and/or `.qwen/sandbox.bashrc` under your project settings directory (`.qwen`) and running `qwen` with `BUILD_SANDBOX=1` to trigger building of your custom sandbox.
|
||||
|
||||
#### Proxied Networking
|
||||
|
||||
All sandboxing methods, including macOS Seatbelt using `*-proxied` profiles, support restricting outbound network traffic through a custom proxy server that can be specified as `GEMINI_SANDBOX_PROXY_COMMAND=<command>`, where `<command>` must start a proxy server that listens on `:::8877` for relevant requests. See `docs/examples/proxy-script.md` for a minimal proxy that only allows `HTTPS` connections to `example.com:443` (e.g. `curl https://example.com`) and declines all other requests. The proxy is started and stopped automatically alongside the sandbox.
|
||||
> TBD
|
||||
|
||||
## Manual Publish
|
||||
|
||||
|
||||
10
Makefile
10
Makefile
@@ -1,9 +1,9 @@
|
||||
# Makefile for gemini-cli
|
||||
# Makefile for qwen-code
|
||||
|
||||
.PHONY: help install build build-sandbox build-all test lint format preflight clean start debug release run-npx create-alias
|
||||
|
||||
help:
|
||||
@echo "Makefile for gemini-cli"
|
||||
@echo "Makefile for qwen-code"
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo " make install - Install npm dependencies"
|
||||
@@ -14,11 +14,11 @@ help:
|
||||
@echo " make format - Format the code"
|
||||
@echo " make preflight - Run formatting, linting, and tests"
|
||||
@echo " make clean - Remove generated files"
|
||||
@echo " make start - Start the Gemini CLI"
|
||||
@echo " make debug - Start the Gemini CLI in debug mode"
|
||||
@echo " make start - Start the Qwen Code CLI"
|
||||
@echo " make debug - Start the Qwen Code CLI in debug mode"
|
||||
@echo ""
|
||||
@echo " make run-npx - Run the CLI using npx (for testing the published package)"
|
||||
@echo " make create-alias - Create a 'gemini' alias for your shell"
|
||||
@echo " make create-alias - Create a 'qwen' alias for your shell"
|
||||
|
||||
install:
|
||||
npm install
|
||||
|
||||
410
README.md
410
README.md
@@ -1,382 +1,152 @@
|
||||
# Qwen Code
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||
[](./LICENSE)
|
||||
[](https://nodejs.org/)
|
||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||
|
||||
**AI-powered command-line workflow tool for developers**
|
||||
**An open-source AI agent that lives in your terminal.**
|
||||
|
||||
[Installation](#installation) • [Quick Start](#quick-start) • [Features](#key-features) • [Documentation](./docs/) • [Contributing](./CONTRIBUTING.md)
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/zh/users/overview">中文</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/de/users/overview">Deutsch</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/fr/users/overview">français</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/ja/users/overview">日本語</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/ru/users/overview">Русский</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/pt-BR/users/overview">Português (Brasil)</a>
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/de/">Deutsch</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/fr">français</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/ja/">日本語</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/ru">Русский</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/zh/">中文</a>
|
||||
|
||||
</div>
|
||||
Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder). It helps you understand large codebases, automate tedious work, and ship faster.
|
||||
|
||||
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
|
||||

|
||||
|
||||
## 💡 Free Options Available
|
||||
## Why Qwen Code?
|
||||
|
||||
Get started with Qwen Code at no cost using any of these free options:
|
||||
|
||||
### 🔥 Qwen OAuth (Recommended)
|
||||
|
||||
- **2,000 requests per day** with no token limits
|
||||
- **60 requests per minute** rate limit
|
||||
- Simply run `qwen` and authenticate with your qwen.ai account
|
||||
- Automatic credential management and refresh
|
||||
- Use `/auth` command to switch to Qwen OAuth if you have initialized with OpenAI compatible mode
|
||||
|
||||
### 🌏 Regional Free Tiers
|
||||
|
||||
- **Mainland China**: ModelScope offers **2,000 free API calls per day**
|
||||
- **International**: OpenRouter provides **up to 1,000 free API calls per day** worldwide
|
||||
|
||||
For detailed setup instructions, see [Authorization](#authorization).
|
||||
|
||||
> [!WARNING]
|
||||
> **Token Usage Notice**: Qwen Code may issue multiple API calls per cycle, resulting in higher token usage (similar to Claude Code). We're actively optimizing API efficiency.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Code Understanding & Editing** - Query and edit large codebases beyond traditional context window limits
|
||||
- **Workflow Automation** - Automate operational tasks like handling pull requests and complex rebases
|
||||
- **Enhanced Parser** - Adapted parser specifically optimized for Qwen-Coder models
|
||||
- **Vision Model Support** - Automatically detect images in your input and seamlessly switch to vision-capable models for multimodal analysis
|
||||
- **OpenAI-compatible, OAuth free tier**: use an OpenAI-compatible API, or sign in with Qwen OAuth to get 2,000 free requests/day.
|
||||
- **Open-source, co-evolving**: both the framework and the Qwen3-Coder model are open-source—and they ship and evolve together.
|
||||
- **Agentic workflow, feature-rich**: rich built-in tools (Skills, SubAgents, Plan Mode) for a full agentic workflow and a Claude Code-like experience.
|
||||
- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code and Zed.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Ensure you have [Node.js version 20](https://nodejs.org/en/download) or higher installed.
|
||||
#### Prerequisites
|
||||
|
||||
```bash
|
||||
# Node.js 20+
|
||||
curl -qL https://www.npmjs.com/install.sh | sh
|
||||
```
|
||||
|
||||
### Install from npm
|
||||
#### NPM (recommended)
|
||||
|
||||
```bash
|
||||
npm install -g @qwen-code/qwen-code@latest
|
||||
qwen --version
|
||||
```
|
||||
|
||||
### Install from source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/QwenLM/qwen-code.git
|
||||
cd qwen-code
|
||||
npm install
|
||||
npm install -g .
|
||||
```
|
||||
|
||||
### Install globally with Homebrew (macOS/Linux)
|
||||
#### Homebrew (macOS, Linux)
|
||||
|
||||
```bash
|
||||
brew install qwen-code
|
||||
```
|
||||
|
||||
## VS Code Extension
|
||||
|
||||
In addition to the CLI tool, Qwen Code also provides a **VS Code extension** that brings AI-powered coding assistance directly into your editor with features like file system operations, native diffing, interactive chat, and more.
|
||||
|
||||
> 📦 The extension is currently in development. For installation, features, and development guide, see the [VS Code Extension README](./packages/vscode-ide-companion/README.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start Qwen Code
|
||||
# Start Qwen Code (interactive)
|
||||
qwen
|
||||
|
||||
# Example commands
|
||||
> Explain this codebase structure
|
||||
> Help me refactor this function
|
||||
> Generate unit tests for this module
|
||||
# Then, in the session:
|
||||
/help
|
||||
/auth
|
||||
```
|
||||
|
||||
### Session Management
|
||||
On first use, you'll be prompted to sign in. You can run `/auth` anytime to switch authentication methods.
|
||||
|
||||
Control your token usage with configurable session limits to optimize costs and performance.
|
||||
Example prompts:
|
||||
|
||||
#### Configure Session Token Limit
|
||||
|
||||
Create or edit `.qwen/settings.json` in your home directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionTokenLimit": 32000
|
||||
}
|
||||
```text
|
||||
What does this project do?
|
||||
Explain the codebase structure.
|
||||
Help me refactor this function.
|
||||
Generate unit tests for this module.
|
||||
```
|
||||
|
||||
#### Session Commands
|
||||
|
||||
- **`/compress`** - Compress conversation history to continue within token limits
|
||||
- **`/clear`** - Clear all conversation history and start fresh
|
||||
- **`/stats`** - Check current token usage and limits
|
||||
|
||||
> 📝 **Note**: Session token limit applies to a single conversation, not cumulative API calls.
|
||||
|
||||
### Vision Model Configuration
|
||||
|
||||
Qwen Code includes intelligent vision model auto-switching that detects images in your input and can automatically switch to vision-capable models for multimodal analysis. **This feature is enabled by default** - when you include images in your queries, you'll see a dialog asking how you'd like to handle the vision model switch.
|
||||
|
||||
#### Skip the Switch Dialog (Optional)
|
||||
|
||||
If you don't want to see the interactive dialog each time, configure the default behavior in your `.qwen/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"vlmSwitchMode": "once"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Available modes:**
|
||||
|
||||
- **`"once"`** - Switch to vision model for this query only, then revert
|
||||
- **`"session"`** - Switch to vision model for the entire session
|
||||
- **`"persist"`** - Continue with current model (no switching)
|
||||
- **Not set** - Show interactive dialog each time (default)
|
||||
|
||||
#### Command Line Override
|
||||
|
||||
You can also set the behavior via command line:
|
||||
|
||||
```bash
|
||||
# Switch once per query
|
||||
qwen --vlm-switch-mode once
|
||||
|
||||
# Switch for entire session
|
||||
qwen --vlm-switch-mode session
|
||||
|
||||
# Never switch automatically
|
||||
qwen --vlm-switch-mode persist
|
||||
```
|
||||
|
||||
#### Disable Vision Models (Optional)
|
||||
|
||||
To completely disable vision model support, add to your `.qwen/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"visionModelPreview": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 💡 **Tip**: In YOLO mode (`--yolo`), vision switching happens automatically without prompts when images are detected.
|
||||
|
||||
### Authorization
|
||||
|
||||
Choose your preferred authentication method based on your needs:
|
||||
|
||||
#### 1. Qwen OAuth (🚀 Recommended - Start in 30 seconds)
|
||||
|
||||
The easiest way to get started - completely free with generous quotas:
|
||||
|
||||
```bash
|
||||
# Just run this command and follow the browser authentication
|
||||
qwen
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. **Instant Setup**: CLI opens your browser automatically
|
||||
2. **One-Click Login**: Authenticate with your qwen.ai account
|
||||
3. **Automatic Management**: Credentials cached locally for future use
|
||||
4. **No Configuration**: Zero setup required - just start coding!
|
||||
|
||||
**Free Tier Benefits:**
|
||||
|
||||
- ✅ **2,000 requests/day** (no token counting needed)
|
||||
- ✅ **60 requests/minute** rate limit
|
||||
- ✅ **Automatic credential refresh**
|
||||
- ✅ **Zero cost** for individual users
|
||||
- ℹ️ **Note**: Model fallback may occur to maintain service quality
|
||||
|
||||
#### 2. OpenAI-Compatible API
|
||||
|
||||
Use API keys for OpenAI or other compatible providers:
|
||||
|
||||
**Configuration Methods:**
|
||||
|
||||
1. **Environment Variables**
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your_api_key_here"
|
||||
export OPENAI_BASE_URL="your_api_endpoint"
|
||||
export OPENAI_MODEL="your_model_choice"
|
||||
```
|
||||
|
||||
2. **Project `.env` File**
|
||||
Create a `.env` file in your project root:
|
||||
```env
|
||||
OPENAI_API_KEY=your_api_key_here
|
||||
OPENAI_BASE_URL=your_api_endpoint
|
||||
OPENAI_MODEL=your_model_choice
|
||||
```
|
||||
|
||||
**API Provider Options**
|
||||
|
||||
> ⚠️ **Regional Notice:**
|
||||
>
|
||||
> - **Mainland China**: Use Alibaba Cloud Bailian or ModelScope
|
||||
> - **International**: Use Alibaba Cloud ModelStudio or OpenRouter
|
||||
|
||||
<details>
|
||||
<summary><b>🇨🇳 For Users in Mainland China</b></summary>
|
||||
<summary>Click to watch a demo video</summary>
|
||||
|
||||
**Option 1: Alibaba Cloud Bailian** ([Apply for API Key](https://bailian.console.aliyun.com/))
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your_api_key_here"
|
||||
export OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
export OPENAI_MODEL="qwen3-coder-plus"
|
||||
```
|
||||
|
||||
**Option 2: ModelScope (Free Tier)** ([Apply for API Key](https://modelscope.cn/docs/model-service/API-Inference/intro))
|
||||
|
||||
- ✅ **2,000 free API calls per day**
|
||||
- ⚠️ Connect your Aliyun account to avoid authentication errors
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your_api_key_here"
|
||||
export OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1"
|
||||
export OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct"
|
||||
```
|
||||
<video src="https://cloud.video.taobao.com/vod/HLfyppnCHplRV9Qhz2xSqeazHeRzYtG-EYJnHAqtzkQ.mp4" controls>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🌍 For International Users</b></summary>
|
||||
## Authentication
|
||||
|
||||
**Option 1: Alibaba Cloud ModelStudio** ([Apply for API Key](https://modelstudio.console.alibabacloud.com/))
|
||||
Qwen Code supports two authentication methods:
|
||||
|
||||
- **Qwen OAuth (recommended & free)**: sign in with your `qwen.ai` account in a browser.
|
||||
- **OpenAI-compatible API**: use `OPENAI_API_KEY` (and optionally a custom base URL / model).
|
||||
|
||||
#### Qwen OAuth (recommended)
|
||||
|
||||
Start `qwen`, then run:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your_api_key_here"
|
||||
export OPENAI_BASE_URL="https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
|
||||
export OPENAI_MODEL="qwen3-coder-plus"
|
||||
/auth
|
||||
```
|
||||
|
||||
**Option 2: OpenRouter (Free Tier Available)** ([Apply for API Key](https://openrouter.ai/))
|
||||
Choose **Qwen OAuth** and complete the browser flow. Your credentials are cached locally so you usually won't need to log in again.
|
||||
|
||||
#### OpenAI-compatible API (API key)
|
||||
|
||||
Environment variables (recommended for CI / headless environments):
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your_api_key_here"
|
||||
export OPENAI_BASE_URL="https://openrouter.ai/api/v1"
|
||||
export OPENAI_MODEL="qwen/qwen3-coder:free"
|
||||
export OPENAI_API_KEY="your-api-key-here"
|
||||
export OPENAI_BASE_URL="https://api.openai.com/v1" # optional
|
||||
export OPENAI_MODEL="gpt-4o" # optional
|
||||
```
|
||||
|
||||
</details>
|
||||
For details (including `.qwen/.env` loading and security notes), see the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/).
|
||||
|
||||
## Usage Examples
|
||||
## Usage
|
||||
|
||||
### 🔍 Explore Codebases
|
||||
As an open-source terminal agent, you can use Qwen Code in four primary ways:
|
||||
|
||||
1. Interactive mode (terminal UI)
|
||||
2. Headless mode (scripts, CI)
|
||||
3. IDE integration (VS Code, Zed)
|
||||
4. TypeScript SDK
|
||||
|
||||
#### Interactive mode
|
||||
|
||||
```bash
|
||||
cd your-project/
|
||||
qwen
|
||||
|
||||
# Architecture analysis
|
||||
> Describe the main pieces of this system's architecture
|
||||
> What are the key dependencies and how do they interact?
|
||||
> Find all API endpoints and their authentication methods
|
||||
```
|
||||
|
||||
### 💻 Code Development
|
||||
Run `qwen` in your project folder to launch the interactive terminal UI. Use `@` to reference local files (for example `@src/main.ts`).
|
||||
|
||||
#### Headless mode
|
||||
|
||||
```bash
|
||||
# Refactoring
|
||||
> Refactor this function to improve readability and performance
|
||||
> Convert this class to use dependency injection
|
||||
> Split this large module into smaller, focused components
|
||||
|
||||
# Code generation
|
||||
> Create a REST API endpoint for user management
|
||||
> Generate unit tests for the authentication module
|
||||
> Add error handling to all database operations
|
||||
cd your-project/
|
||||
qwen -p "your question"
|
||||
```
|
||||
|
||||
### 🔄 Automate Workflows
|
||||
Use `-p` to run Qwen Code without the interactive UI—ideal for scripts, automation, and CI/CD. Learn more: [Headless mode](https://qwenlm.github.io/qwen-code-docs/en/users/features/headless).
|
||||
|
||||
```bash
|
||||
# Git automation
|
||||
> Analyze git commits from the last 7 days, grouped by feature
|
||||
> Create a changelog from recent commits
|
||||
> Find all TODO comments and create GitHub issues
|
||||
#### IDE integration
|
||||
|
||||
# File operations
|
||||
> Convert all images in this directory to PNG format
|
||||
> Rename all test files to follow the *.test.ts pattern
|
||||
> Find and remove all console.log statements
|
||||
```
|
||||
Use Qwen Code inside your editor (VS Code and Zed):
|
||||
|
||||
### 🐛 Debugging & Analysis
|
||||
- [Use in VS Code](https://qwenlm.github.io/qwen-code-docs/en/users/integration-vscode/)
|
||||
- [Use in Zed](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/)
|
||||
|
||||
```bash
|
||||
# Performance analysis
|
||||
> Identify performance bottlenecks in this React component
|
||||
> Find all N+1 query problems in the codebase
|
||||
#### TypeScript SDK
|
||||
|
||||
# Security audit
|
||||
> Check for potential SQL injection vulnerabilities
|
||||
> Find all hardcoded credentials or API keys
|
||||
```
|
||||
Build on top of Qwen Code with the TypeScript SDK:
|
||||
|
||||
## Popular Tasks
|
||||
|
||||
### 📚 Understand New Codebases
|
||||
|
||||
```text
|
||||
> What are the core business logic components?
|
||||
> What security mechanisms are in place?
|
||||
> How does the data flow through the system?
|
||||
> What are the main design patterns used?
|
||||
> Generate a dependency graph for this module
|
||||
```
|
||||
|
||||
### 🔨 Code Refactoring & Optimization
|
||||
|
||||
```text
|
||||
> What parts of this module can be optimized?
|
||||
> Help me refactor this class to follow SOLID principles
|
||||
> Add proper error handling and logging
|
||||
> Convert callbacks to async/await pattern
|
||||
> Implement caching for expensive operations
|
||||
```
|
||||
|
||||
### 📝 Documentation & Testing
|
||||
|
||||
```text
|
||||
> Generate comprehensive JSDoc comments for all public APIs
|
||||
> Write unit tests with edge cases for this component
|
||||
> Create API documentation in OpenAPI format
|
||||
> Add inline comments explaining complex algorithms
|
||||
> Generate a README for this module
|
||||
```
|
||||
|
||||
### 🚀 Development Acceleration
|
||||
|
||||
```text
|
||||
> Set up a new Express server with authentication
|
||||
> Create a React component with TypeScript and tests
|
||||
> Implement a rate limiter middleware
|
||||
> Add database migrations for new schema
|
||||
> Configure CI/CD pipeline for this project
|
||||
```
|
||||
- [Use the Qwen Code SDK](./packages/sdk-typescript/README.md)
|
||||
|
||||
## Commands & Shortcuts
|
||||
|
||||
@@ -386,6 +156,7 @@ qwen
|
||||
- `/clear` - Clear conversation history
|
||||
- `/compress` - Compress history to save tokens
|
||||
- `/stats` - Show current session information
|
||||
- `/bug` - Submit a bug report
|
||||
- `/exit` or `/quit` - Exit Qwen Code
|
||||
|
||||
### Keyboard Shortcuts
|
||||
@@ -394,6 +165,19 @@ qwen
|
||||
- `Ctrl+D` - Exit (on empty line)
|
||||
- `Up/Down` - Navigate command history
|
||||
|
||||
> Learn more about [Commands](https://qwenlm.github.io/qwen-code-docs/en/users/features/commands/)
|
||||
>
|
||||
> **Tip**: In YOLO mode (`--yolo`), vision switching happens automatically without prompts when images are detected. Learn more about [Approval Mode](https://qwenlm.github.io/qwen-code-docs/en/users/features/approval-mode/)
|
||||
|
||||
## Configuration
|
||||
|
||||
Qwen Code can be configured via `settings.json`, environment variables, and CLI flags.
|
||||
|
||||
- **User settings**: `~/.qwen/settings.json`
|
||||
- **Project settings**: `.qwen/settings.json`
|
||||
|
||||
See [settings](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/) for available options and precedence.
|
||||
|
||||
## Benchmark Results
|
||||
|
||||
### Terminal-Bench Performance
|
||||
@@ -403,24 +187,18 @@ qwen
|
||||
| Qwen Code | Qwen3-Coder-480A35 | 37.5% |
|
||||
| Qwen Code | Qwen3-Coder-30BA3B | 31.3% |
|
||||
|
||||
## Development & Contributing
|
||||
## Ecosystem
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) to learn how to contribute to the project.
|
||||
Looking for a graphical interface?
|
||||
|
||||
For detailed authentication setup, see the [authentication guide](./docs/cli/authentication.md).
|
||||
- [**Gemini CLI Desktop**](https://github.com/Piebald-AI/gemini-cli-desktop) A cross-platform desktop/web/mobile UI for Qwen Code
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues, check the [troubleshooting guide](docs/troubleshooting.md).
|
||||
If you encounter issues, check the [troubleshooting guide](https://qwenlm.github.io/qwen-code-docs/en/users/support/troubleshooting/).
|
||||
|
||||
To report a bug from within the CLI, run `/bug` and include a short title and repro steps.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
This project is based on [Google Gemini CLI](https://github.com/google-gemini/gemini-cli). We acknowledge and appreciate the excellent work of the Gemini CLI team. Our main contribution focuses on parser-level adaptations to better support Qwen-Coder models.
|
||||
|
||||
## License
|
||||
|
||||
[LICENSE](./LICENSE)
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#QwenLM/qwen-code&Date)
|
||||
|
||||
@@ -43,7 +43,6 @@ Qwen Code uses JSON settings files for persistent configuration. There are four
|
||||
In addition to a project settings file, a project's `.qwen` directory can contain other project-specific files related to Qwen Code's operation, such as:
|
||||
|
||||
- [Custom sandbox profiles](../features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`).
|
||||
- [Agent Skills](../features/skills) (experimental) under `.qwen/skills/` (each Skill is a directory containing a `SKILL.md`).
|
||||
|
||||
### Available settings in `settings.json`
|
||||
|
||||
@@ -381,8 +380,6 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
|
||||
| `--experimental-acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Experimental. |
|
||||
| `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. |
|
||||
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
|
||||
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
|
||||
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export default {
|
||||
commands: 'Commands',
|
||||
'sub-agents': 'SubAgents',
|
||||
skills: 'Skills (Experimental)',
|
||||
headless: 'Headless Mode',
|
||||
checkpointing: {
|
||||
display: 'hidden',
|
||||
@@ -10,4 +9,5 @@ export default {
|
||||
mcp: 'MCP',
|
||||
'token-caching': 'Token Caching',
|
||||
sandbox: 'Sandboxing',
|
||||
language: 'i18n',
|
||||
};
|
||||
|
||||
@@ -48,7 +48,7 @@ Commands specifically for controlling interface and output language.
|
||||
| → `ui [language]` | Set UI interface language | `/language ui zh-CN` |
|
||||
| → `output [language]` | Set LLM output language | `/language output Chinese` |
|
||||
|
||||
- Available UI languages: `zh-CN` (Simplified Chinese), `en-US` (English)
|
||||
- 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.
|
||||
|
||||
### 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.
|
||||
|
||||
| Command | Description | Usage Examples |
|
||||
| --------------- | ----------------------------------------------- | ------------------------------------------------ |
|
||||
| `/help` | Display help information for available commands | `/help` or `/?` |
|
||||
| `/about` | Display version information | `/about` |
|
||||
| `/stats` | Display detailed statistics for current session | `/stats` |
|
||||
| `/settings` | Open settings editor | `/settings` |
|
||||
| `/auth` | Change authentication method | `/auth` |
|
||||
| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` |
|
||||
| `/copy` | Copy last output content to clipboard | `/copy` |
|
||||
| `/quit-confirm` | Show confirmation dialog before quitting | `/quit-confirm` (shortcut: press `Ctrl+C` twice) |
|
||||
| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` |
|
||||
| Command | Description | Usage Examples |
|
||||
| ----------- | ----------------------------------------------- | -------------------------------- |
|
||||
| `/help` | Display help information for available commands | `/help` or `/?` |
|
||||
| `/about` | Display version information | `/about` |
|
||||
| `/stats` | Display detailed statistics for current session | `/stats` |
|
||||
| `/settings` | Open settings editor | `/settings` |
|
||||
| `/auth` | Change authentication method | `/auth` |
|
||||
| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` |
|
||||
| `/copy` | Copy last output content to clipboard | `/copy` |
|
||||
| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` |
|
||||
|
||||
### 1.6 Common Shortcuts
|
||||
|
||||
|
||||
@@ -189,20 +189,19 @@ qwen -p "Write code" --output-format stream-json --include-partial-messages | jq
|
||||
|
||||
Key command-line options for headless usage:
|
||||
|
||||
| Option | Description | Example |
|
||||
| ---------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
||||
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
||||
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
||||
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
||||
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
||||
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
||||
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
|
||||
| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` |
|
||||
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
|
||||
| `--experimental-skills` | Enable experimental Skills (registers the `skill` tool) | `qwen --experimental-skills -p "What Skills are available?"` |
|
||||
| Option | Description | Example |
|
||||
| ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
||||
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
||||
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
||||
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
||||
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
||||
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
||||
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
|
||||
| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` |
|
||||
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
|
||||
|
||||
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](../configuration/settings).
|
||||
|
||||
|
||||
136
docs/users/features/language.md
Normal file
136
docs/users/features/language.md
Normal 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 Code’s “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 CLI’s 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 haven’t 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 you’d 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
|
||||
@@ -1,282 +0,0 @@
|
||||
# Agent Skills (Experimental)
|
||||
|
||||
> Create, manage, and share Skills to extend Qwen Code’s capabilities.
|
||||
|
||||
This guide shows you how to create, use, and manage Agent Skills in **Qwen Code**. Skills are modular capabilities that extend the model’s effectiveness through organized folders containing instructions (and optionally scripts/resources).
|
||||
|
||||
> [!note]
|
||||
>
|
||||
> Skills are currently **experimental** and must be enabled with `--experimental-skills`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Qwen Code (recent version)
|
||||
- Run with the experimental flag enabled:
|
||||
|
||||
```bash
|
||||
qwen --experimental-skills
|
||||
```
|
||||
|
||||
- Basic familiarity with Qwen Code ([Quickstart](../quickstart.md))
|
||||
|
||||
## What are Agent Skills?
|
||||
|
||||
Agent Skills package expertise into discoverable capabilities. Each Skill consists of a `SKILL.md` file with instructions that the model can load when relevant, plus optional supporting files like scripts and templates.
|
||||
|
||||
### How Skills are invoked
|
||||
|
||||
Skills are **model-invoked** — the model autonomously decides when to use them based on your request and the Skill’s description. This is different from slash commands, which are **user-invoked** (you explicitly type `/command`).
|
||||
|
||||
### Benefits
|
||||
|
||||
- Extend Qwen Code for your workflows
|
||||
- Share expertise across your team via git
|
||||
- Reduce repetitive prompting
|
||||
- Compose multiple Skills for complex tasks
|
||||
|
||||
## Create a Skill
|
||||
|
||||
Skills are stored as directories containing a `SKILL.md` file.
|
||||
|
||||
### Personal Skills
|
||||
|
||||
Personal Skills are available across all your projects. Store them in `~/.qwen/skills/`:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.qwen/skills/my-skill-name
|
||||
```
|
||||
|
||||
Use personal Skills for:
|
||||
|
||||
- Your individual workflows and preferences
|
||||
- Experimental Skills you’re developing
|
||||
- Personal productivity helpers
|
||||
|
||||
### Project Skills
|
||||
|
||||
Project Skills are shared with your team. Store them in `.qwen/skills/` within your project:
|
||||
|
||||
```bash
|
||||
mkdir -p .qwen/skills/my-skill-name
|
||||
```
|
||||
|
||||
Use project Skills for:
|
||||
|
||||
- Team workflows and conventions
|
||||
- Project-specific expertise
|
||||
- Shared utilities and scripts
|
||||
|
||||
Project Skills can be checked into git and automatically become available to teammates.
|
||||
|
||||
## Write `SKILL.md`
|
||||
|
||||
Create a `SKILL.md` file with YAML frontmatter and Markdown content:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: your-skill-name
|
||||
description: Brief description of what this Skill does and when to use it
|
||||
---
|
||||
|
||||
# Your Skill Name
|
||||
|
||||
## Instructions
|
||||
Provide clear, step-by-step guidance for Qwen Code.
|
||||
|
||||
## Examples
|
||||
Show concrete examples of using this Skill.
|
||||
```
|
||||
|
||||
### Field requirements
|
||||
|
||||
Qwen Code currently validates that:
|
||||
|
||||
- `name` is a non-empty string
|
||||
- `description` is a non-empty string
|
||||
|
||||
Recommended conventions (not strictly enforced yet):
|
||||
|
||||
- Use lowercase letters, numbers, and hyphens in `name`
|
||||
- Make `description` specific: include both **what** the Skill does and **when** to use it (key words users will naturally mention)
|
||||
|
||||
## Add supporting files
|
||||
|
||||
Create additional files alongside `SKILL.md`:
|
||||
|
||||
```text
|
||||
my-skill/
|
||||
├── SKILL.md (required)
|
||||
├── reference.md (optional documentation)
|
||||
├── examples.md (optional examples)
|
||||
├── scripts/
|
||||
│ └── helper.py (optional utility)
|
||||
└── templates/
|
||||
└── template.txt (optional template)
|
||||
```
|
||||
|
||||
Reference these files from `SKILL.md`:
|
||||
|
||||
````markdown
|
||||
For advanced usage, see [reference.md](reference.md).
|
||||
|
||||
Run the helper script:
|
||||
|
||||
```bash
|
||||
python scripts/helper.py input.txt
|
||||
```
|
||||
````
|
||||
|
||||
## View available Skills
|
||||
|
||||
When `--experimental-skills` is enabled, Qwen Code discovers Skills from:
|
||||
|
||||
- Personal Skills: `~/.qwen/skills/`
|
||||
- Project Skills: `.qwen/skills/`
|
||||
|
||||
To view available Skills, ask Qwen Code directly:
|
||||
|
||||
```text
|
||||
What Skills are available?
|
||||
```
|
||||
|
||||
Or inspect the filesystem:
|
||||
|
||||
```bash
|
||||
# List personal Skills
|
||||
ls ~/.qwen/skills/
|
||||
|
||||
# List project Skills (if in a project directory)
|
||||
ls .qwen/skills/
|
||||
|
||||
# View a specific Skill’s content
|
||||
cat ~/.qwen/skills/my-skill/SKILL.md
|
||||
```
|
||||
|
||||
## Test a Skill
|
||||
|
||||
After creating a Skill, test it by asking questions that match your description.
|
||||
|
||||
Example: if your description mentions “PDF files”:
|
||||
|
||||
```text
|
||||
Can you help me extract text from this PDF?
|
||||
```
|
||||
|
||||
The model autonomously decides to use your Skill if it matches the request — you don’t need to explicitly invoke it.
|
||||
|
||||
## Debug a Skill
|
||||
|
||||
If Qwen Code doesn’t use your Skill, check these common issues:
|
||||
|
||||
### Make the description specific
|
||||
|
||||
Too vague:
|
||||
|
||||
```yaml
|
||||
description: Helps with documents
|
||||
```
|
||||
|
||||
Specific:
|
||||
|
||||
```yaml
|
||||
description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDFs, forms, or document extraction.
|
||||
```
|
||||
|
||||
### Verify file path
|
||||
|
||||
- Personal Skills: `~/.qwen/skills/<skill-name>/SKILL.md`
|
||||
- Project Skills: `.qwen/skills/<skill-name>/SKILL.md`
|
||||
|
||||
```bash
|
||||
# Personal
|
||||
ls ~/.qwen/skills/my-skill/SKILL.md
|
||||
|
||||
# Project
|
||||
ls .qwen/skills/my-skill/SKILL.md
|
||||
```
|
||||
|
||||
### Check YAML syntax
|
||||
|
||||
Invalid YAML prevents the Skill metadata from loading correctly.
|
||||
|
||||
```bash
|
||||
cat SKILL.md | head -n 15
|
||||
```
|
||||
|
||||
Ensure:
|
||||
|
||||
- Opening `---` on line 1
|
||||
- Closing `---` before Markdown content
|
||||
- Valid YAML syntax (no tabs, correct indentation)
|
||||
|
||||
### View errors
|
||||
|
||||
Run Qwen Code with debug mode to see Skill loading errors:
|
||||
|
||||
```bash
|
||||
qwen --experimental-skills --debug
|
||||
```
|
||||
|
||||
## Share Skills with your team
|
||||
|
||||
You can share Skills through project repositories:
|
||||
|
||||
1. Add the Skill under `.qwen/skills/`
|
||||
2. Commit and push
|
||||
3. Teammates pull the changes and run with `--experimental-skills`
|
||||
|
||||
```bash
|
||||
git add .qwen/skills/
|
||||
git commit -m "Add team Skill for PDF processing"
|
||||
git push
|
||||
```
|
||||
|
||||
## Update a Skill
|
||||
|
||||
Edit `SKILL.md` directly:
|
||||
|
||||
```bash
|
||||
# Personal Skill
|
||||
code ~/.qwen/skills/my-skill/SKILL.md
|
||||
|
||||
# Project Skill
|
||||
code .qwen/skills/my-skill/SKILL.md
|
||||
```
|
||||
|
||||
Changes take effect the next time you start Qwen Code. If Qwen Code is already running, restart it to load the updates.
|
||||
|
||||
## Remove a Skill
|
||||
|
||||
Delete the Skill directory:
|
||||
|
||||
```bash
|
||||
# Personal
|
||||
rm -rf ~/.qwen/skills/my-skill
|
||||
|
||||
# Project
|
||||
rm -rf .qwen/skills/my-skill
|
||||
git commit -m "Remove unused Skill"
|
||||
```
|
||||
|
||||
## Best practices
|
||||
|
||||
### Keep Skills focused
|
||||
|
||||
One Skill should address one capability:
|
||||
|
||||
- Focused: “PDF form filling”, “Excel analysis”, “Git commit messages”
|
||||
- Too broad: “Document processing” (split into smaller Skills)
|
||||
|
||||
### Write clear descriptions
|
||||
|
||||
Help the model discover when to use Skills by including specific triggers:
|
||||
|
||||
```yaml
|
||||
description: Analyze Excel spreadsheets, create pivot tables, and generate charts. Use when working with Excel files, spreadsheets, or .xlsx data.
|
||||
```
|
||||
|
||||
### Test with your team
|
||||
|
||||
- Does the Skill activate when expected?
|
||||
- Are the instructions clear?
|
||||
- Are there missing examples or edge cases?
|
||||
@@ -112,7 +112,6 @@ export interface CliArgs {
|
||||
allowedMcpServerNames: string[] | undefined;
|
||||
allowedTools: string[] | undefined;
|
||||
experimentalAcp: boolean | undefined;
|
||||
experimentalSkills: boolean | undefined;
|
||||
extensions: string[] | undefined;
|
||||
listExtensions: boolean | undefined;
|
||||
openaiLogging: boolean | undefined;
|
||||
@@ -308,11 +307,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
type: 'boolean',
|
||||
description: 'Starts the agent in ACP mode',
|
||||
})
|
||||
.option('experimental-skills', {
|
||||
type: 'boolean',
|
||||
description: 'Enable experimental Skills feature',
|
||||
default: false,
|
||||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
|
||||
@@ -957,7 +951,6 @@ export async function loadCliConfig(
|
||||
maxSessionTurns:
|
||||
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
|
||||
experimentalZedIntegration: argv.experimentalAcp || false,
|
||||
experimentalSkills: argv.experimentalSkills || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
extensions: allExtensions,
|
||||
blockedMcpServers,
|
||||
|
||||
@@ -56,17 +56,6 @@ vi.mock('simple-git', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./extensions/github.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('./extensions/github.js')>();
|
||||
return {
|
||||
...actual,
|
||||
downloadFromGitHubRelease: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Mocked GitHub release download failure')),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
|
||||
@@ -41,17 +41,6 @@ vi.mock('simple-git', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../extensions/github.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../extensions/github.js')>();
|
||||
return {
|
||||
...actual,
|
||||
downloadFromGitHubRelease: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Mocked GitHub release download failure')),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { type LoadedSettings, SettingScope } from '../config/settings.js';
|
||||
import { performInitialAuth } from './auth.js';
|
||||
import { validateTheme } from './theme.js';
|
||||
import { initializeI18n } from '../i18n/index.js';
|
||||
import { initializeLlmOutputLanguage } from '../ui/commands/languageCommand.js';
|
||||
|
||||
export interface InitializationResult {
|
||||
authError: string | null;
|
||||
@@ -41,6 +42,9 @@ export async function initializeApp(
|
||||
'auto';
|
||||
await initializeI18n(languageSetting);
|
||||
|
||||
// Auto-detect and set LLM output language on first use
|
||||
initializeLlmOutputLanguage();
|
||||
|
||||
const authType = settings.merged.security?.auth?.selectedType;
|
||||
const authError = await performInitialAuth(config, authType);
|
||||
|
||||
|
||||
@@ -461,7 +461,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
experimentalAcp: undefined,
|
||||
experimentalSkills: undefined,
|
||||
extensions: undefined,
|
||||
listExtensions: undefined,
|
||||
openaiLogging: undefined,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* Copyright 2025 Qwen team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -8,15 +8,21 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
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
|
||||
let currentLanguage: SupportedLanguage = 'en';
|
||||
let translations: Record<string, string> = {};
|
||||
let translations: Record<string, string | string[]> = {};
|
||||
|
||||
// Cache
|
||||
type TranslationDict = Record<string, string>;
|
||||
type TranslationValue = string | string[];
|
||||
type TranslationDict = Record<string, TranslationValue>;
|
||||
const translationCache: Record<string, TranslationDict> = {};
|
||||
const loadingPromises: Record<string, Promise<TranslationDict>> = {};
|
||||
|
||||
@@ -52,11 +58,13 @@ export function detectSystemLanguage(): SupportedLanguage {
|
||||
if (envLang?.startsWith('zh')) return 'zh';
|
||||
if (envLang?.startsWith('en')) return 'en';
|
||||
if (envLang?.startsWith('ru')) return 'ru';
|
||||
if (envLang?.startsWith('de')) return 'de';
|
||||
|
||||
try {
|
||||
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
|
||||
if (locale.startsWith('zh')) return 'zh';
|
||||
if (locale.startsWith('ru')) return 'ru';
|
||||
if (locale.startsWith('de')) return 'de';
|
||||
} catch {
|
||||
// Fallback to default
|
||||
}
|
||||
@@ -224,9 +232,25 @@ export function getCurrentLanguage(): SupportedLanguage {
|
||||
|
||||
export function t(key: string, params?: Record<string, string>): string {
|
||||
const translation = translations[key] ?? key;
|
||||
if (Array.isArray(translation)) {
|
||||
return key;
|
||||
}
|
||||
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(
|
||||
lang?: SupportedLanguage | 'auto',
|
||||
): Promise<void> {
|
||||
|
||||
48
packages/cli/src/i18n/languages.ts
Normal file
48
packages/cli/src/i18n/languages.ts
Normal 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';
|
||||
}
|
||||
@@ -102,8 +102,8 @@ export default {
|
||||
'Theme "{{themeName}}" not found.': 'Theme "{{themeName}}" not found.',
|
||||
'Theme "{{themeName}}" not found in selected scope.':
|
||||
'Theme "{{themeName}}" not found in selected scope.',
|
||||
'clear the screen and conversation history':
|
||||
'clear the screen and conversation history',
|
||||
'Clear conversation history and free up context':
|
||||
'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':
|
||||
@@ -604,9 +604,10 @@ export default {
|
||||
// ============================================================================
|
||||
// Commands - Clear
|
||||
// ============================================================================
|
||||
'Clearing terminal and resetting chat.':
|
||||
'Clearing terminal and resetting chat.',
|
||||
'Clearing terminal.': 'Clearing terminal.',
|
||||
'Starting a new session, resetting chat, and clearing terminal.':
|
||||
'Starting a new session, resetting chat, and clearing terminal.',
|
||||
'Starting a new session and clearing.':
|
||||
'Starting a new session and clearing.',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Compress
|
||||
@@ -927,192 +928,138 @@ export default {
|
||||
// ============================================================================
|
||||
'Waiting for user confirmation...': 'Waiting for user confirmation...',
|
||||
'(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...',
|
||||
'Navigating the slime mold...': 'Navigating the slime mold...',
|
||||
'Consulting the digital spirits...': 'Consulting the digital spirits...',
|
||||
'Reticulating splines...': 'Reticulating splines...',
|
||||
'Warming up the AI hamsters...': 'Warming up the AI hamsters...',
|
||||
'Asking the magic conch shell...': 'Asking the magic conch shell...',
|
||||
'Generating witty retort...': 'Generating witty retort...',
|
||||
'Polishing the algorithms...': 'Polishing the algorithms...',
|
||||
"Don't rush perfection (or my code)...":
|
||||
|
||||
// ============================================================================
|
||||
// Loading Phrases
|
||||
// ============================================================================
|
||||
WITTY_LOADING_PHRASES: [
|
||||
"I'm Feeling Lucky",
|
||||
'Shipping awesomeness... ',
|
||||
'Painting the serifs back on...',
|
||||
'Navigating the slime mold...',
|
||||
'Consulting the digital spirits...',
|
||||
'Reticulating splines...',
|
||||
'Warming up the AI hamsters...',
|
||||
'Asking the magic conch shell...',
|
||||
'Generating witty retort...',
|
||||
'Polishing the algorithms...',
|
||||
"Don't rush perfection (or my code)...",
|
||||
'Brewing fresh bytes...': 'Brewing fresh bytes...',
|
||||
'Counting electrons...': 'Counting electrons...',
|
||||
'Engaging cognitive processors...': 'Engaging cognitive processors...',
|
||||
'Checking for syntax errors in the universe...':
|
||||
'Brewing fresh bytes...',
|
||||
'Counting electrons...',
|
||||
'Engaging cognitive processors...',
|
||||
'Checking for syntax errors in the universe...',
|
||||
'One moment, optimizing humor...': 'One moment, optimizing humor...',
|
||||
'Shuffling punchlines...': 'Shuffling punchlines...',
|
||||
'Untangling neural nets...': 'Untangling neural nets...',
|
||||
'Compiling brilliance...': 'Compiling brilliance...',
|
||||
'Loading wit.exe...': 'Loading wit.exe...',
|
||||
'Summoning the cloud of wisdom...': 'Summoning the cloud of wisdom...',
|
||||
'Preparing a witty response...': 'Preparing a witty response...',
|
||||
"Just a sec, I'm debugging reality...":
|
||||
'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...': 'Confuzzling the options...',
|
||||
'Tuning the cosmic frequencies...': 'Tuning the cosmic frequencies...',
|
||||
'Crafting a response worthy of your patience...':
|
||||
'Confuzzling the options...',
|
||||
'Tuning the cosmic frequencies...',
|
||||
'Crafting a response worthy of your patience...',
|
||||
'Compiling the 1s and 0s...': 'Compiling the 1s and 0s...',
|
||||
'Resolving dependencies... and existential crises...':
|
||||
'Compiling the 1s and 0s...',
|
||||
'Resolving dependencies... and existential crises...',
|
||||
'Defragmenting memories... both RAM and personal...':
|
||||
'Defragmenting memories... both RAM and personal...',
|
||||
'Rebooting the humor module...': 'Rebooting the humor module...',
|
||||
'Caching the essentials (mostly cat memes)...':
|
||||
'Rebooting the humor module...',
|
||||
'Caching the essentials (mostly cat memes)...',
|
||||
'Optimizing for ludicrous speed': 'Optimizing for ludicrous speed',
|
||||
"Swapping bits... don't tell the bytes...":
|
||||
'Optimizing for ludicrous speed',
|
||||
"Swapping bits... don't tell the bytes...",
|
||||
'Garbage collecting... be right back...':
|
||||
'Garbage collecting... be right back...',
|
||||
'Assembling the interwebs...': 'Assembling the interwebs...',
|
||||
'Converting coffee into code...': 'Converting coffee into code...',
|
||||
'Updating the syntax for reality...': 'Updating the syntax for reality...',
|
||||
'Rewiring the synapses...': 'Rewiring the synapses...',
|
||||
'Looking for a misplaced semicolon...':
|
||||
'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...": "Greasin' the cogs of the machine...",
|
||||
'Pre-heating the servers...': 'Pre-heating the servers...',
|
||||
'Calibrating the flux capacitor...': 'Calibrating the flux capacitor...',
|
||||
'Engaging the improbability drive...': 'Engaging the improbability drive...',
|
||||
'Channeling the Force...': 'Channeling the Force...',
|
||||
'Aligning the stars for optimal response...':
|
||||
"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...': 'So say we all...',
|
||||
'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...",
|
||||
'Preparing to dazzle you with brilliance...':
|
||||
'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...":
|
||||
"Just a tick, I'm polishing my wit...",
|
||||
"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 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 moment, I'm tuning the algorithms...":
|
||||
"Just a moment, I'm tuning the algorithms...",
|
||||
'Warp speed engaged...': 'Warp speed engaged...',
|
||||
'Mining for more Dilithium crystals...':
|
||||
'Warp speed engaged...',
|
||||
'Mining for more Dilithium crystals...',
|
||||
"Don't panic...": "Don't panic...",
|
||||
'Following the white rabbit...': 'Following the white rabbit...',
|
||||
'The truth is in here... somewhere...':
|
||||
"Don't panic...",
|
||||
'Following the white rabbit...',
|
||||
'The truth is in here... somewhere...',
|
||||
'Blowing on the cartridge...': 'Blowing on the cartridge...',
|
||||
'Loading... Do a barrel roll!': 'Loading... Do a barrel roll!',
|
||||
'Waiting for the respawn...': 'Waiting for the respawn...',
|
||||
'Finishing the Kessel Run in less than 12 parsecs...':
|
||||
'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...":
|
||||
"The cake is not a lie, it's just still loading...",
|
||||
'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...",
|
||||
"Pressing 'A' to continue...": "Pressing 'A' to continue...",
|
||||
'Herding digital cats...': 'Herding digital cats...',
|
||||
'Polishing the pixels...': 'Polishing the pixels...',
|
||||
'Finding a suitable loading screen pun...':
|
||||
"Pressing 'A' to continue...",
|
||||
'Herding digital cats...',
|
||||
'Polishing the pixels...',
|
||||
'Finding a suitable loading screen pun...',
|
||||
'Distracting you with this witty phrase...':
|
||||
'Distracting you with this witty phrase...',
|
||||
'Almost there... probably...': 'Almost there... probably...',
|
||||
'Our hamsters are working as fast as they can...':
|
||||
'Almost there... probably...',
|
||||
'Our hamsters are working as fast as they can...',
|
||||
'Giving Cloudy a pat on the head...': 'Giving Cloudy a pat on the head...',
|
||||
'Petting the cat...': 'Petting the cat...',
|
||||
'Rickrolling my boss...': 'Rickrolling my boss...',
|
||||
'Never gonna give you up, never gonna let you down...':
|
||||
'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...': 'Slapping the bass...',
|
||||
'Tasting the snozberries...': 'Tasting the snozberries...',
|
||||
"I'm going the distance, I'm going for speed...":
|
||||
'Slapping the bass...',
|
||||
'Tasting the snozberries...',
|
||||
"I'm going the distance, I'm going for speed...",
|
||||
'Is this the real life? Is this just fantasy?...':
|
||||
'Is this the real life? Is this just fantasy?...',
|
||||
"I've got a good feeling about this...":
|
||||
"I've got a good feeling about this...",
|
||||
'Poking the bear...': 'Poking the bear...',
|
||||
'Doing research on the latest memes...':
|
||||
'Poking the bear...',
|
||||
'Doing research on the latest memes...',
|
||||
'Figuring out how to make this more witty...':
|
||||
'Figuring out how to make this more witty...',
|
||||
'Hmmm... let me think...': 'Hmmm... let me think...',
|
||||
'What do you call a fish with no eyes? A fsh...':
|
||||
'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 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 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...',
|
||||
"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...',
|
||||
'Searching for the correct USB orientation...':
|
||||
'Applying percussive maintenance...',
|
||||
'Searching for the correct USB orientation...',
|
||||
'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...',
|
||||
'Trying to exit Vim...': 'Trying to exit Vim...',
|
||||
'Spinning up the hamster wheel...': 'Spinning up the hamster wheel...',
|
||||
"That's not a bug, it's an undocumented feature...":
|
||||
'Trying to exit Vim...',
|
||||
'Spinning up the hamster wheel...',
|
||||
"That's not a bug, it's an undocumented feature...",
|
||||
'Engage.': 'Engage.',
|
||||
"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...',
|
||||
'Communing with the machine spirit...':
|
||||
'Engage.',
|
||||
"I'll be back... with an answer.",
|
||||
'My other process is a TARDIS...',
|
||||
'Communing with the machine spirit...',
|
||||
'Letting the thoughts marinate...': 'Letting the thoughts marinate...',
|
||||
'Just remembered where I put my keys...':
|
||||
'Letting the thoughts marinate...',
|
||||
'Just remembered where I put my keys...',
|
||||
'Pondering the orb...': 'Pondering the orb...',
|
||||
"I've seen things you people wouldn't believe... like a user who reads loading messages.":
|
||||
'Pondering the orb...',
|
||||
"I've seen things you people wouldn't believe... like a user who reads loading messages.",
|
||||
'Initiating thoughtful gaze...': 'Initiating thoughtful gaze...',
|
||||
"What's a computer's favorite snack? Microchips.":
|
||||
'Initiating thoughtful gaze...',
|
||||
"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#.",
|
||||
'Charging the laser... pew pew!': 'Charging the laser... pew pew!',
|
||||
'Dividing by zero... just kidding!': 'Dividing by zero... just kidding!',
|
||||
'Looking for an adult superviso... I mean, processing.':
|
||||
'Charging the laser... pew pew!',
|
||||
'Dividing by zero... just kidding!',
|
||||
'Looking for an adult superviso... I mean, processing.',
|
||||
'Making it go beep boop.': 'Making it go beep boop.',
|
||||
'Buffering... because even AIs need a moment.':
|
||||
'Making it go beep boop.',
|
||||
'Buffering... because even AIs need a moment.',
|
||||
'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.',
|
||||
'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.',
|
||||
'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.',
|
||||
'My other loading screen is even funnier.':
|
||||
'Recalibrating the humor-o-meter.',
|
||||
'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...",
|
||||
'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.",
|
||||
'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...',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -103,8 +103,8 @@ export default {
|
||||
'Theme "{{themeName}}" not found.': 'Тема "{{themeName}}" не найдена.',
|
||||
'Theme "{{themeName}}" not found in selected scope.':
|
||||
'Тема "{{themeName}}" не найдена в выбранной области.',
|
||||
'clear the screen and conversation history':
|
||||
'Очистка экрана и истории диалога',
|
||||
'Clear conversation history and free up context':
|
||||
'Очистить историю диалога и освободить контекст',
|
||||
'Compresses the context by replacing it with a summary.':
|
||||
'Сжатие контекста заменой на краткую сводку',
|
||||
'open full Qwen Code documentation in your browser':
|
||||
@@ -313,6 +313,7 @@ export default {
|
||||
'Tool Output Truncation Lines': 'Лимит строк вывода инструментов',
|
||||
'Folder Trust': 'Доверие к папке',
|
||||
'Vision Model Preview': 'Визуальная модель (предпросмотр)',
|
||||
'Tool Schema Compliance': 'Соответствие схеме инструмента',
|
||||
// Варианты перечислений настроек
|
||||
'Auto (detect from system)': 'Авто (определить из системы)',
|
||||
Text: 'Текст',
|
||||
@@ -341,8 +342,8 @@ export default {
|
||||
'Установка предпочитаемого внешнего редактора',
|
||||
'Manage extensions': 'Управление расширениями',
|
||||
'List active extensions': 'Показать активные расширения',
|
||||
'Update extensions. Usage: update |--all':
|
||||
'Обновить расширения. Использование: update |--all',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
'Обновить расширения. Использование: update <extension-names>|--all',
|
||||
'manage IDE integration': 'Управление интеграцией с IDE',
|
||||
'check status of IDE integration': 'Проверить статус интеграции с IDE',
|
||||
'install required IDE companion for {{ideName}}':
|
||||
@@ -400,7 +401,8 @@ export default {
|
||||
'Set LLM output language': 'Установка языка вывода LLM',
|
||||
'Usage: /language ui [zh-CN|en-US]':
|
||||
'Использование: /language ui [zh-CN|en-US|ru-RU]',
|
||||
'Usage: /language output ': 'Использование: /language output ',
|
||||
'Usage: /language output <language>':
|
||||
'Использование: /language output <language>',
|
||||
'Example: /language output 中文': 'Пример: /language output 中文',
|
||||
'Example: /language output English': 'Пример: /language output English',
|
||||
'Example: /language output 日本語': 'Пример: /language output 日本語',
|
||||
@@ -417,9 +419,8 @@ export default {
|
||||
'To request additional UI language packs, please open an issue on GitHub.':
|
||||
'Для запроса дополнительных языковых пакетов интерфейса, пожалуйста, создайте обращение на GitHub.',
|
||||
'Available options:': 'Доступные варианты:',
|
||||
' - zh-CN: Simplified Chinese': ' - zh-CN: Упрощенный китайский',
|
||||
' - en-US: English': ' - en-US: Английский',
|
||||
' - ru-RU: Russian': ' - ru-RU: Русский',
|
||||
' - zh-CN: Simplified Chinese': ' - zh-CN: Упрощенный китайский',
|
||||
' - en-US: English': ' - en-US: Английский',
|
||||
'Set UI language to Simplified Chinese (zh-CN)':
|
||||
'Установить язык интерфейса на упрощенный китайский (zh-CN)',
|
||||
'Set UI language to English (en-US)':
|
||||
@@ -435,8 +436,8 @@ export default {
|
||||
'Режим подтверждения изменен на: {{mode}}',
|
||||
'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})':
|
||||
'Режим подтверждения изменен на: {{mode}} (сохранено в настройках {{scope}}{{location}})',
|
||||
'Usage: /approval-mode [--session|--user|--project]':
|
||||
'Использование: /approval-mode [--session|--user|--project]',
|
||||
'Usage: /approval-mode <mode> [--session|--user|--project]':
|
||||
'Использование: /approval-mode <mode> [--session|--user|--project]',
|
||||
'Scope subcommands do not accept additional arguments.':
|
||||
'Подкоманды области не принимают дополнительных аргументов.',
|
||||
'Plan mode - Analyze only, do not modify files or execute commands':
|
||||
@@ -588,8 +589,8 @@ export default {
|
||||
'Ошибка при экспорте диалога: {{error}}',
|
||||
'Conversation shared to {{filePath}}': 'Диалог экспортирован в {{filePath}}',
|
||||
'No conversation found to share.': 'Нет диалога для экспорта.',
|
||||
'Share the current conversation to a markdown or json file. Usage: /chat share <путь-к-файлу>':
|
||||
'Экспортировать текущий диалог в markdown или json файл. Использование: /chat share <путь-к-файлу>',
|
||||
'Share the current conversation to a markdown or json file. Usage: /chat share <file>':
|
||||
'Экспортировать текущий диалог в markdown или json файл. Использование: /chat share <файл>',
|
||||
|
||||
// ============================================================================
|
||||
// Команды - Резюме
|
||||
@@ -618,8 +619,9 @@ export default {
|
||||
// ============================================================================
|
||||
// Команды - Очистка
|
||||
// ============================================================================
|
||||
'Clearing terminal and resetting chat.': 'Очистка терминала и сброс чата.',
|
||||
'Clearing terminal.': 'Очистка терминала.',
|
||||
'Starting a new session, resetting chat, and clearing terminal.':
|
||||
'Начало новой сессии, сброс чата и очистка терминала.',
|
||||
'Starting a new session and clearing.': 'Начало новой сессии и очистка.',
|
||||
|
||||
// ============================================================================
|
||||
// Команды - Сжатие
|
||||
@@ -650,8 +652,8 @@ export default {
|
||||
'Команда /directory add не поддерживается в ограничительных профилях песочницы. Пожалуйста, используйте --include-directories при запуске сессии.',
|
||||
"Error adding '{{path}}': {{error}}":
|
||||
"Ошибка при добавлении '{{path}}': {{error}}",
|
||||
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}':
|
||||
'Успешно добавлены файлы GEMINI.md из следующих директорий (если они есть):\n- {{directories}}',
|
||||
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}':
|
||||
'Успешно добавлены файлы QWEN.md из следующих директорий (если они есть):\n- {{directories}}',
|
||||
'Error refreshing memory: {{error}}':
|
||||
'Ошибка при обновлении памяти: {{error}}',
|
||||
'Successfully added directories:\n- {{directories}}':
|
||||
@@ -884,6 +886,7 @@ export default {
|
||||
// Экран выхода / Статистика
|
||||
// ============================================================================
|
||||
'Agent powering down. Goodbye!': 'Агент завершает работу. До свидания!',
|
||||
'To continue this session, run': 'Для продолжения этой сессии, выполните',
|
||||
'Interaction Summary': 'Сводка взаимодействия',
|
||||
'Session ID:': 'ID сессии:',
|
||||
'Tool Calls:': 'Вызовы инструментов:',
|
||||
@@ -943,179 +946,140 @@ export default {
|
||||
'Waiting for user confirmation...':
|
||||
'Ожидание подтверждения от пользователя...',
|
||||
'(esc to cancel, {{time}})': '(esc для отмены, {{time}})',
|
||||
"I'm Feeling Lucky": 'Мне повезёт!',
|
||||
'Shipping awesomeness... ': 'Доставляем крутизну... ',
|
||||
'Painting the serifs back on...': 'Рисуем засечки на буквах...',
|
||||
'Navigating the slime mold...': 'Пробираемся через слизевиков..',
|
||||
'Consulting the digital spirits...': 'Советуемся с цифровыми духами...',
|
||||
'Reticulating splines...': 'Сглаживание сплайнов...',
|
||||
'Warming up the AI hamsters...': 'Разогреваем ИИ-хомячков...',
|
||||
'Asking the magic conch shell...': 'Спрашиваем волшебную ракушку...',
|
||||
'Generating witty retort...': 'Генерируем остроумный ответ...',
|
||||
'Polishing the algorithms...': 'Полируем алгоритмы...',
|
||||
"Don't rush perfection (or my code)...":
|
||||
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// Loading Phrases
|
||||
// ============================================================================
|
||||
WITTY_LOADING_PHRASES: [
|
||||
'Мне повезёт!',
|
||||
'Доставляем крутизну... ',
|
||||
'Рисуем засечки на буквах...',
|
||||
'Пробираемся через слизевиков..',
|
||||
'Советуемся с цифровыми духами...',
|
||||
'Сглаживание сплайнов...',
|
||||
'Разогреваем ИИ-хомячков...',
|
||||
'Спрашиваем волшебную ракушку...',
|
||||
'Генерируем остроумный ответ...',
|
||||
'Полируем алгоритмы...',
|
||||
'Не торопите совершенство (или мой код)...',
|
||||
'Brewing fresh bytes...': 'Завариваем свежие байты...',
|
||||
'Counting electrons...': 'Пересчитываем электроны...',
|
||||
'Engaging cognitive processors...': 'Задействуем когнитивные процессоры...',
|
||||
'Checking for syntax errors in the universe...':
|
||||
'Завариваем свежие байты...',
|
||||
'Пересчитываем электроны...',
|
||||
'Задействуем когнитивные процессоры...',
|
||||
'Ищем синтаксические ошибки во вселенной...',
|
||||
'One moment, optimizing humor...': 'Секундочку, оптимизируем юмор...',
|
||||
'Shuffling punchlines...': 'Перетасовываем панчлайны...',
|
||||
'Untangling neural nets...': 'Распутаваем нейросети...',
|
||||
'Compiling brilliance...': 'Компилируем гениальность...',
|
||||
'Loading wit.exe...': 'Загружаем yumor.exe...',
|
||||
'Summoning the cloud of wisdom...': 'Призываем облако мудрости...',
|
||||
'Preparing a witty response...': 'Готовим остроумный ответ...',
|
||||
"Just a sec, I'm debugging reality...": 'Секунду, идёт отладка реальности...',
|
||||
'Confuzzling the options...': 'Запутываем варианты...',
|
||||
'Tuning the cosmic frequencies...': 'Настраиваем космические частоты...',
|
||||
'Crafting a response worthy of your patience...':
|
||||
'Секундочку, оптимизируем юмор...',
|
||||
'Перетасовываем панчлайны...',
|
||||
'Распутаваем нейросети...',
|
||||
'Компилируем гениальность...',
|
||||
'Загружаем yumor.exe...',
|
||||
'Призываем облако мудрости...',
|
||||
'Готовим остроумный ответ...',
|
||||
'Секунду, идёт отладка реальности...',
|
||||
'Запутываем варианты...',
|
||||
'Настраиваем космические частоты...',
|
||||
'Создаем ответ, достойный вашего терпения...',
|
||||
'Compiling the 1s and 0s...': 'Компилируем единички и нолики...',
|
||||
'Resolving dependencies... and existential crises...':
|
||||
'Компилируем единички и нолики...',
|
||||
'Разрешаем зависимости... и экзистенциальные кризисы...',
|
||||
'Defragmenting memories... both RAM and personal...':
|
||||
'Дефрагментация памяти... и оперативной, и личной...',
|
||||
'Rebooting the humor module...': 'Перезагрузка модуля юмора...',
|
||||
'Caching the essentials (mostly cat memes)...':
|
||||
'Перезагрузка модуля юмора...',
|
||||
'Кэшируем самое важное (в основном мемы с котиками)...',
|
||||
'Optimizing for ludicrous speed': 'Оптимизация для безумной скорости',
|
||||
"Swapping bits... don't tell the bytes...":
|
||||
'Оптимизация для безумной скорости',
|
||||
'Меняем биты... только байтам не говорите...',
|
||||
'Garbage collecting... be right back...': 'Сборка мусора... скоро вернусь...',
|
||||
'Assembling the interwebs...': 'Сборка интернетов...',
|
||||
'Converting coffee into code...': 'Превращаем кофе в код...',
|
||||
'Updating the syntax for reality...': 'Обновляем синтаксис реальности...',
|
||||
'Rewiring the synapses...': 'Переподключаем синапсы...',
|
||||
'Looking for a misplaced semicolon...': 'Ищем лишнюю точку с запятой...',
|
||||
"Greasin' the cogs of the machine...": 'Смазываем шестерёнки машины...',
|
||||
'Pre-heating the servers...': 'Разогреваем серверы...',
|
||||
'Calibrating the flux capacitor...': 'Калибруем потоковый накопитель...',
|
||||
'Engaging the improbability drive...': 'Включаем двигатель невероятности...',
|
||||
'Channeling the Force...': 'Направляем Силу...',
|
||||
'Aligning the stars for optimal response...':
|
||||
'Сборка мусора... скоро вернусь...',
|
||||
'Сборка интернетов...',
|
||||
'Превращаем кофе в код...',
|
||||
'Обновляем синтаксис реальности...',
|
||||
'Переподключаем синапсы...',
|
||||
'Ищем лишнюю точку с запятой...',
|
||||
'Смазываем шестерёнки машины...',
|
||||
'Разогреваем серверы...',
|
||||
'Калибруем потоковый накопитель...',
|
||||
'Включаем двигатель невероятности...',
|
||||
'Направляем Силу...',
|
||||
'Выравниваем звёзды для оптимального ответа...',
|
||||
'So say we all...': 'Так скажем мы все...',
|
||||
'Loading the next great idea...': 'Загрузка следующей великой идеи...',
|
||||
"Just a moment, I'm in the zone...": 'Минутку, я в потоке...',
|
||||
'Preparing to dazzle you with brilliance...':
|
||||
'Так скажем мы все...',
|
||||
'Загрузка следующей великой идеи...',
|
||||
'Минутку, я в потоке...',
|
||||
'Готовлюсь ослепить вас гениальностью...',
|
||||
"Just a tick, I'm polishing my wit...": 'Секунду, полирую остроумие...',
|
||||
"Hold tight, I'm crafting a masterpiece...": 'Держитесь, создаю шедевр...',
|
||||
"Just a jiffy, I'm debugging the universe...":
|
||||
'Секунду, полирую остроумие...',
|
||||
'Держитесь, создаю шедевр...',
|
||||
'Мигом, отлаживаю вселенную...',
|
||||
"Just a moment, I'm aligning the pixels...": 'Момент, выравниваю пиксели...',
|
||||
"Just a sec, I'm optimizing the humor...": 'Секунду, оптимизирую юмор...',
|
||||
"Just a moment, I'm tuning the algorithms...":
|
||||
'Момент, выравниваю пиксели...',
|
||||
'Секунду, оптимизирую юмор...',
|
||||
'Момент, настраиваю алгоритмы...',
|
||||
'Warp speed engaged...': 'Варп-скорость включена...',
|
||||
'Mining for more Dilithium crystals...': 'Добываем кристаллы дилития...',
|
||||
"Don't panic...": 'Без паники...',
|
||||
'Following the white rabbit...': 'Следуем за белым кроликом...',
|
||||
'The truth is in here... somewhere...': 'Истина где-то здесь... внутри...',
|
||||
'Blowing on the cartridge...': 'Продуваем картридж...',
|
||||
'Loading... Do a barrel roll!': 'Загрузка... Сделай бочку!',
|
||||
'Waiting for the respawn...': 'Ждем респауна...',
|
||||
'Finishing the Kessel Run in less than 12 parsecs...':
|
||||
'Варп-прыжок активирован...',
|
||||
'Добываем кристаллы дилития...',
|
||||
'Без паники...',
|
||||
'Следуем за белым кроликом...',
|
||||
'Истина где-то здесь... внутри...',
|
||||
'Продуваем картридж...',
|
||||
'Загрузка... Сделай бочку!',
|
||||
'Ждем респауна...',
|
||||
'Делаем Дугу Кесселя менее чем за 12 парсеков...',
|
||||
"The cake is not a lie, it's just still loading...":
|
||||
'Тортик — не ложь, он просто ещё грузится...',
|
||||
'Fiddling with the character creation screen...':
|
||||
'Возимся с экраном создания персонажа...',
|
||||
"Just a moment, I'm finding the right meme...":
|
||||
'Минутку, ищу подходящий мем...',
|
||||
"Pressing 'A' to continue...": "Нажимаем 'A' для продолжения...",
|
||||
'Herding digital cats...': 'Пасём цифровых котов...',
|
||||
'Polishing the pixels...': 'Полируем пиксели...',
|
||||
'Finding a suitable loading screen pun...':
|
||||
"Нажимаем 'A' для продолжения...",
|
||||
'Пасём цифровых котов...',
|
||||
'Полируем пиксели...',
|
||||
'Ищем подходящий каламбур для экрана загрузки...',
|
||||
'Distracting you with this witty phrase...':
|
||||
'Отвлекаем вас этой остроумной фразой...',
|
||||
'Almost there... probably...': 'Почти готово... вроде...',
|
||||
'Our hamsters are working as fast as they can...':
|
||||
'Почти готово... вроде...',
|
||||
'Наши хомячки работают изо всех сил...',
|
||||
'Giving Cloudy a pat on the head...': 'Гладим Облачко по голове...',
|
||||
'Petting the cat...': 'Гладим кота...',
|
||||
'Rickrolling my boss...': 'Рикроллим начальника...',
|
||||
'Never gonna give you up, never gonna let you down...':
|
||||
'Гладим Облачко по голове...',
|
||||
'Гладим кота...',
|
||||
'Рикроллим начальника...',
|
||||
'Never gonna give you up, never gonna let you down...',
|
||||
'Slapping the bass...': 'Лабаем бас-гитару...',
|
||||
'Tasting the snozberries...': 'Пробуем снузберри на вкус...',
|
||||
"I'm going the distance, I'm going for speed...":
|
||||
'Лабаем бас-гитару...',
|
||||
'Пробуем снузберри на вкус...',
|
||||
'Иду до конца, иду на скорость...',
|
||||
'Is this the real life? Is this just fantasy?...':
|
||||
'Is this the real life? Is this just fantasy?...',
|
||||
"I've got a good feeling about this...": 'У меня хорошее предчувствие...',
|
||||
'Poking the bear...': 'Дразним медведя... (Не лезь...)',
|
||||
'Doing research on the latest memes...': 'Изучаем свежие мемы...',
|
||||
'Figuring out how to make this more witty...':
|
||||
'У меня хорошее предчувствие...',
|
||||
'Дразним медведя... (Не лезь...)',
|
||||
'Изучаем свежие мемы...',
|
||||
'Думаем, как сделать это остроумнее...',
|
||||
'Hmmm... let me think...': 'Хмм... дайте подумать...',
|
||||
'What do you call a fish with no eyes? A fsh...':
|
||||
'Хмм... дайте подумать...',
|
||||
'Как называется бумеранг, который не возвращается? Палка...',
|
||||
'Why did the computer go to therapy? It had too many bytes...':
|
||||
'Почему компьютер простудился? Потому что оставил окна открытыми...',
|
||||
"Why don't programmers like nature? It has too many bugs...":
|
||||
'Почему программисты не любят гулять на улице? Там среда не настроена...',
|
||||
'Why do programmers prefer dark mode? Because light attracts bugs...':
|
||||
'Почему программисты предпочитают тёмную тему? Потому что в темноте не видно багов...',
|
||||
'Why did the developer go broke? Because they used up all their cache...':
|
||||
'Почему разработчик разорился? Потому что потратил весь свой кэш...',
|
||||
"What can you do with a broken pencil? Nothing, it's pointless...":
|
||||
'Что можно делать со сломанным карандашом? Ничего — он тупой...',
|
||||
'Applying percussive maintenance...': 'Провожу настройку методом тыка...',
|
||||
'Searching for the correct USB orientation...':
|
||||
'Провожу настройку методом тыка...',
|
||||
'Ищем, какой стороной вставлять флешку...',
|
||||
'Ensuring the magic smoke stays inside the wires...':
|
||||
'Следим, чтобы волшебный дым не вышел из проводов...',
|
||||
'Rewriting in Rust for no particular reason...':
|
||||
'Переписываем всё на Rust без особой причины...',
|
||||
'Trying to exit Vim...': 'Пытаемся выйти из Vim...',
|
||||
'Spinning up the hamster wheel...': 'Раскручиваем колесо для хомяка...',
|
||||
"That's not a bug, it's an undocumented feature...": 'Это не баг, а фича...',
|
||||
'Engage.': 'Поехали!',
|
||||
"I'll be back... with an answer.": 'Я вернусь... с ответом.',
|
||||
'My other process is a TARDIS...': 'Мой другой процесс — это ТАРДИС...',
|
||||
'Communing with the machine spirit...': 'Общаемся с духом машины...',
|
||||
'Letting the thoughts marinate...': 'Даем мыслям замариноваться...',
|
||||
'Just remembered where I put my keys...':
|
||||
'Пытаемся выйти из Vim...',
|
||||
'Раскручиваем колесо для хомяка...',
|
||||
'Это не баг, а фича...',
|
||||
'Поехали!',
|
||||
'Я вернусь... с ответом.',
|
||||
'Мой другой процесс — это ТАРДИС...',
|
||||
'Общаемся с духом машины...',
|
||||
'Даем мыслям замариноваться...',
|
||||
'Только что вспомнил, куда положил ключи...',
|
||||
'Pondering the orb...': 'Размышляю над сферой...',
|
||||
"I've seen things you people wouldn't believe... like a user who reads loading messages.":
|
||||
'Я видел такое, во что вы, люди, просто не поверите... например, пользователя, читающего сообщения загрузки.',
|
||||
'Initiating thoughtful gaze...': 'Инициируем задумчивый взгляд...',
|
||||
"What's a computer's favorite snack? Microchips.":
|
||||
'Размышляю над сферой...',
|
||||
'Я видел такое, что вам, людям, и не снилось... пользователя, читающего эти сообщения.',
|
||||
'Инициируем задумчивый взгляд...',
|
||||
'Что сервер заказывает в баре? Пинг-коладу.',
|
||||
"Why do Java developers wear glasses? Because they don't C#.":
|
||||
'Почему Java-разработчики не убираются дома? Они ждут сборщик мусора...',
|
||||
'Charging the laser... pew pew!': 'Заряжаем лазер... пиу-пиу!',
|
||||
'Dividing by zero... just kidding!': 'Делим на ноль... шучу!',
|
||||
'Looking for an adult superviso... I mean, processing.':
|
||||
'Заряжаем лазер... пиу-пиу!',
|
||||
'Делим на ноль... шучу!',
|
||||
'Ищу взрослых для присмот... в смысле, обрабатываю.',
|
||||
'Making it go beep boop.': 'Делаем бип-буп.',
|
||||
'Buffering... because even AIs need a moment.':
|
||||
'Буферизация... даже ИИ нужно мгновение.',
|
||||
'Entangling quantum particles for a faster response...':
|
||||
'Делаем бип-буп.',
|
||||
'Буферизация... даже ИИ нужно время подумать.',
|
||||
'Запутываем квантовые частицы для быстрого ответа...',
|
||||
'Polishing the chrome... on the algorithms.':
|
||||
'Полируем хром... на алгоритмах.',
|
||||
'Are you not entertained? (Working on it!)':
|
||||
'Вы ещё не развлеклись?! Разве вы не за этим сюда пришли?!',
|
||||
'Summoning the code gremlins... to help, of course.':
|
||||
'Призываем гремлинов кода... для помощи, конечно же.',
|
||||
'Just waiting for the dial-up tone to finish...':
|
||||
'Ждем, пока закончится звук dial-up модема...',
|
||||
'Recalibrating the humor-o-meter.': 'Перекалибровка юморометра.',
|
||||
'My other loading screen is even funnier.':
|
||||
'Перекалибровка юморометра.',
|
||||
'Мой другой экран загрузки ещё смешнее.',
|
||||
"Pretty sure there's a cat walking on the keyboard somewhere...":
|
||||
'Кажется, где-то по клавиатуре гуляет кот...',
|
||||
'Enhancing... Enhancing... Still loading.':
|
||||
'Улучшаем... Ещё улучшаем... Всё ещё грузится.',
|
||||
"It's not a bug, it's a feature... of this loading screen.":
|
||||
'Это не баг, это фича... экрана загрузки.',
|
||||
'Have you tried turning it off and on again? (The loading screen, not me.)':
|
||||
'Пробовали выключить и включить снова? (Экран загрузки, не меня!)',
|
||||
'Constructing additional pylons...': 'Нужно построить больше пилонов...',
|
||||
'Нужно построить больше пилонов...',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -101,7 +101,7 @@ export default {
|
||||
'Theme "{{themeName}}" not found.': '未找到主题 "{{themeName}}"。',
|
||||
'Theme "{{themeName}}" not found in selected scope.':
|
||||
'在所选作用域中未找到主题 "{{themeName}}"。',
|
||||
'clear the screen and conversation history': '清屏并清除对话历史',
|
||||
'Clear conversation history and free up context': '清除对话历史并释放上下文',
|
||||
'Compresses the context by replacing it with a summary.':
|
||||
'通过用摘要替换来压缩上下文',
|
||||
'open full Qwen Code documentation in your browser':
|
||||
@@ -573,8 +573,9 @@ export default {
|
||||
// ============================================================================
|
||||
// Commands - Clear
|
||||
// ============================================================================
|
||||
'Clearing terminal and resetting chat.': '正在清屏并重置聊天',
|
||||
'Clearing terminal.': '正在清屏',
|
||||
'Starting a new session, resetting chat, and clearing terminal.':
|
||||
'正在开始新会话,重置聊天并清屏。',
|
||||
'Starting a new session and clearing.': '正在开始新会话并清屏。',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Compress
|
||||
@@ -880,165 +881,39 @@ export default {
|
||||
// ============================================================================
|
||||
'Waiting for user confirmation...': '等待用户确认...',
|
||||
'(esc to cancel, {{time}})': '(按 esc 取消,{{time}})',
|
||||
"I'm Feeling Lucky": '我感觉很幸运',
|
||||
'Shipping awesomeness... ': '正在运送精彩内容... ',
|
||||
'Painting the serifs back on...': '正在重新绘制衬线...',
|
||||
'Navigating the slime mold...': '正在导航粘液霉菌...',
|
||||
'Consulting the digital spirits...': '正在咨询数字精灵...',
|
||||
'Reticulating splines...': '正在网格化样条曲线...',
|
||||
'Warming up the AI hamsters...': '正在预热 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...': '正在洗牌笑点...',
|
||||
'Untangling neural nets...': '正在解开神经网络...',
|
||||
'Compiling brilliance...': '正在编译智慧...',
|
||||
'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...':
|
||||
'正在整理记忆碎片...包括 RAM 和个人记忆...',
|
||||
'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...': '正在建造额外的能量塔...',
|
||||
WITTY_LOADING_PHRASES: [
|
||||
// --- 职场搬砖系列 ---
|
||||
'正在努力搬砖,请稍候...',
|
||||
'老板在身后,快加载啊!',
|
||||
'头发掉光前,一定能加载完...',
|
||||
'服务器正在深呼吸,准备放大招...',
|
||||
'正在向服务器投喂咖啡...',
|
||||
|
||||
// --- 大厂黑话系列 ---
|
||||
'正在赋能全链路,寻找关键抓手...',
|
||||
'正在降本增效,优化加载路径...',
|
||||
'正在打破部门壁垒,沉淀方法论...',
|
||||
'正在拥抱变化,迭代核心价值...',
|
||||
'正在对齐颗粒度,打磨底层逻辑...',
|
||||
'大力出奇迹,正在强行加载...',
|
||||
|
||||
// --- 程序员自嘲系列 ---
|
||||
'只要我不写代码,代码就没有 Bug...',
|
||||
'正在把 Bug 转化为 Feature...',
|
||||
'只要我不尴尬,Bug 就追不上我...',
|
||||
'正在试图理解去年的自己写了什么...',
|
||||
'正在猿力觉醒中,请耐心等待...',
|
||||
|
||||
// --- 合作愉快系列 ---
|
||||
'正在询问产品经理:这需求是真的吗?',
|
||||
'正在给产品经理画饼,请稍等...',
|
||||
|
||||
// --- 温暖治愈系列 ---
|
||||
'每一行代码,都在努力让世界变得更好一点点...',
|
||||
'每一个伟大的想法,都值得这份耐心的等待...',
|
||||
'别急,美好的事物总是需要一点时间去酝酿...',
|
||||
'愿你的代码永无 Bug,愿你的梦想终将成真...',
|
||||
'哪怕只有 0.1% 的进度,也是在向目标靠近...',
|
||||
'加载的是字节,承载的是对技术的热爱...',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -13,6 +13,16 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
|
||||
vi.mock('../../i18n/index.js', () => ({
|
||||
setLanguageAsync: vi.fn().mockResolvedValue(undefined),
|
||||
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),
|
||||
}));
|
||||
|
||||
@@ -61,7 +71,10 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
|
||||
// Import modules after mocking
|
||||
import * as i18n from '../../i18n/index.js';
|
||||
import { languageCommand } from './languageCommand.js';
|
||||
import {
|
||||
languageCommand,
|
||||
initializeLlmOutputLanguage,
|
||||
} from './languageCommand.js';
|
||||
|
||||
describe('languageCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
@@ -186,6 +199,39 @@ describe('languageCommand', () => {
|
||||
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', () => {
|
||||
@@ -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 () => {
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
@@ -481,6 +555,8 @@ describe('languageCommand', () => {
|
||||
const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name);
|
||||
expect(nestedNames).toContain('zh-CN');
|
||||
expect(nestedNames).toContain('en-US');
|
||||
expect(nestedNames).toContain('ru-RU');
|
||||
expect(nestedNames).toContain('de-DE');
|
||||
});
|
||||
|
||||
it('should have action that sets language', async () => {
|
||||
@@ -542,6 +618,9 @@ describe('languageCommand', () => {
|
||||
const enUSSubcommand = uiSubcommand?.subCommands?.find(
|
||||
(c) => c.name === 'en-US',
|
||||
);
|
||||
const deDESubcommand = uiSubcommand?.subCommands?.find(
|
||||
(c) => c.name === 'de-DE',
|
||||
);
|
||||
|
||||
it('zh-CN should have aliases', () => {
|
||||
expect(zhCNSubcommand?.altNames).toContain('zh');
|
||||
@@ -553,6 +632,12 @@ describe('languageCommand', () => {
|
||||
expect(enUSSubcommand?.altNames).toContain('english');
|
||||
});
|
||||
|
||||
it('de-DE should have aliases', () => {
|
||||
expect(deDESubcommand?.altNames).toContain('de');
|
||||
expect(deDESubcommand?.altNames).toContain('german');
|
||||
expect(deDESubcommand?.altNames).toContain('deutsch');
|
||||
});
|
||||
|
||||
it('zh-CN action should set Chinese', async () => {
|
||||
if (!zhCNSubcommand?.action) {
|
||||
throw new Error('zh-CN subcommand must have an action.');
|
||||
@@ -583,6 +668,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 () => {
|
||||
if (!zhCNSubcommand?.action) {
|
||||
throw new Error('zh-CN subcommand must have an action.');
|
||||
@@ -597,4 +697,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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2025 Qwen team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -15,51 +15,72 @@ import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
setLanguageAsync,
|
||||
getCurrentLanguage,
|
||||
detectSystemLanguage,
|
||||
getLanguageNameFromLocale,
|
||||
type SupportedLanguage,
|
||||
t,
|
||||
} from '../../i18n/index.js';
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
type LanguageDefinition,
|
||||
} from '../../i18n/languages.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { Storage } from '@qwen-code/qwen-code-core';
|
||||
|
||||
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 marker "-->" 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.
|
||||
*/
|
||||
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}
|
||||
|
||||
**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.**
|
||||
## Tool / system outputs
|
||||
Raw tool/system outputs may contain fixed-format English. Preserve them verbatim, and if needed, add a short **${language}** explanation below.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -81,12 +176,7 @@ function getCurrentLlmOutputLanguage(): string | null {
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
// Extract language name from the first line
|
||||
// Template format: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY"
|
||||
const match = content.match(/^#.*?(\w+)\s+Output Language Rule/i);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
return extractLlmOutputLanguageFromRuleFileContent(content);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
@@ -127,18 +217,11 @@ async function setUiLanguage(
|
||||
// Reload commands to update their descriptions with the new language
|
||||
context.ui.reloadCommands();
|
||||
|
||||
// Map language codes to friendly display names
|
||||
const langDisplayNames: Partial<Record<SupportedLanguage, string>> = {
|
||||
zh: '中文(zh-CN)',
|
||||
en: 'English(en-US)',
|
||||
ru: 'Русский (ru-RU)',
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t('UI language changed to {{lang}}', {
|
||||
lang: langDisplayNames[lang] || lang,
|
||||
lang: formatUiLanguageDisplay(lang),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -151,7 +234,9 @@ function generateLlmOutputLanguageRuleFile(
|
||||
): Promise<MessageActionReturn> {
|
||||
try {
|
||||
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
|
||||
const dir = path.dirname(filePath);
|
||||
@@ -195,16 +280,6 @@ export const languageCommand: SlashCommand = {
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
const { services } = context;
|
||||
|
||||
if (!services.config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Configuration not available.'),
|
||||
};
|
||||
}
|
||||
|
||||
const trimmedArgs = args.trim();
|
||||
|
||||
// If no arguments, show current language settings and usage
|
||||
@@ -212,13 +287,15 @@ export const languageCommand: SlashCommand = {
|
||||
const currentUiLang = getCurrentLanguage();
|
||||
const currentLlmLang = getCurrentLlmOutputLanguage();
|
||||
const message = [
|
||||
t('Current UI language: {{lang}}', { lang: currentUiLang }),
|
||||
t('Current UI language: {{lang}}', {
|
||||
lang: formatUiLanguageDisplay(currentUiLang as SupportedLanguage),
|
||||
}),
|
||||
currentLlmLang
|
||||
? t('Current LLM output language: {{lang}}', { lang: currentLlmLang })
|
||||
: t('LLM output language not set'),
|
||||
'',
|
||||
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')}`,
|
||||
].join('\n');
|
||||
|
||||
@@ -229,115 +306,21 @@ export const languageCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
|
||||
// Parse subcommand
|
||||
const parts = trimmedArgs.split(/\s+/);
|
||||
const subcommand = parts[0].toLowerCase();
|
||||
|
||||
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'),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle backward compatibility for /language [lang]
|
||||
const targetLang = parseUiLanguageArg(trimmedArgs);
|
||||
if (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: [
|
||||
{
|
||||
@@ -358,11 +341,14 @@ export const languageCommand: SlashCommand = {
|
||||
content: [
|
||||
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(' - zh-CN: Simplified Chinese'),
|
||||
t(' - en-US: English'),
|
||||
...SUPPORTED_LANGUAGES.map(
|
||||
(o) => ` - ${o.id}: ${t(o.fullName)}`,
|
||||
),
|
||||
'',
|
||||
t(
|
||||
'To request additional UI language packs, please open an issue on GitHub.',
|
||||
@@ -371,99 +357,20 @@ export const languageCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
const targetLang = parseUiLanguageArg(trimmedArgs);
|
||||
if (!targetLang) {
|
||||
return {
|
||||
type: 'message',
|
||||
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);
|
||||
},
|
||||
subCommands: [
|
||||
{
|
||||
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');
|
||||
},
|
||||
},
|
||||
],
|
||||
subCommands: SUPPORTED_LANGUAGES.map(createUiLanguageSubCommand),
|
||||
},
|
||||
{
|
||||
name: 'output',
|
||||
@@ -496,3 +403,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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,19 +8,22 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useLoadingIndicator } from './useLoadingIndicator.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import {
|
||||
WITTY_LOADING_PHRASES,
|
||||
PHRASE_CHANGE_INTERVAL_MS,
|
||||
} from './usePhraseCycler.js';
|
||||
import { PHRASE_CHANGE_INTERVAL_MS } from './usePhraseCycler.js';
|
||||
import * as i18n from '../../i18n/index.js';
|
||||
|
||||
const MOCK_WITTY_PHRASES = ['Phrase 1', 'Phrase 2', 'Phrase 3'];
|
||||
|
||||
describe('useLoadingIndicator', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(i18n, 'ta').mockReturnValue(MOCK_WITTY_PHRASES);
|
||||
vi.spyOn(i18n, 't').mockImplementation((key) => key);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers(); // Restore real timers after each test
|
||||
act(() => vi.runOnlyPendingTimers);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with default values when Idle', () => {
|
||||
@@ -28,9 +31,7 @@ describe('useLoadingIndicator', () => {
|
||||
useLoadingIndicator(StreamingState.Idle),
|
||||
);
|
||||
expect(result.current.elapsedTime).toBe(0);
|
||||
expect(WITTY_LOADING_PHRASES).toContain(
|
||||
result.current.currentLoadingPhrase,
|
||||
);
|
||||
expect(MOCK_WITTY_PHRASES).toContain(result.current.currentLoadingPhrase);
|
||||
});
|
||||
|
||||
it('should reflect values when Responding', async () => {
|
||||
@@ -40,18 +41,14 @@ describe('useLoadingIndicator', () => {
|
||||
|
||||
// Initial state before timers advance
|
||||
expect(result.current.elapsedTime).toBe(0);
|
||||
expect(WITTY_LOADING_PHRASES).toContain(
|
||||
result.current.currentLoadingPhrase,
|
||||
);
|
||||
expect(MOCK_WITTY_PHRASES).toContain(result.current.currentLoadingPhrase);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 1);
|
||||
});
|
||||
|
||||
// Phrase should cycle if PHRASE_CHANGE_INTERVAL_MS has passed
|
||||
expect(WITTY_LOADING_PHRASES).toContain(
|
||||
result.current.currentLoadingPhrase,
|
||||
);
|
||||
expect(MOCK_WITTY_PHRASES).toContain(result.current.currentLoadingPhrase);
|
||||
});
|
||||
|
||||
it('should show waiting phrase and retain elapsedTime when WaitingForConfirmation', async () => {
|
||||
@@ -104,9 +101,7 @@ describe('useLoadingIndicator', () => {
|
||||
rerender({ streamingState: StreamingState.Responding });
|
||||
});
|
||||
expect(result.current.elapsedTime).toBe(0); // Should reset
|
||||
expect(WITTY_LOADING_PHRASES).toContain(
|
||||
result.current.currentLoadingPhrase,
|
||||
);
|
||||
expect(MOCK_WITTY_PHRASES).toContain(result.current.currentLoadingPhrase);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
@@ -130,9 +125,7 @@ describe('useLoadingIndicator', () => {
|
||||
});
|
||||
|
||||
expect(result.current.elapsedTime).toBe(0);
|
||||
expect(WITTY_LOADING_PHRASES).toContain(
|
||||
result.current.currentLoadingPhrase,
|
||||
);
|
||||
expect(MOCK_WITTY_PHRASES).toContain(result.current.currentLoadingPhrase);
|
||||
|
||||
// Timer should not advance
|
||||
await act(async () => {
|
||||
|
||||
@@ -8,13 +8,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import {
|
||||
usePhraseCycler,
|
||||
WITTY_LOADING_PHRASES,
|
||||
PHRASE_CHANGE_INTERVAL_MS,
|
||||
} from './usePhraseCycler.js';
|
||||
import * as i18n from '../../i18n/index.js';
|
||||
|
||||
const MOCK_WITTY_PHRASES = ['Phrase 1', 'Phrase 2', 'Phrase 3'];
|
||||
|
||||
describe('usePhraseCycler', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(i18n, 'ta').mockReturnValue(MOCK_WITTY_PHRASES);
|
||||
vi.spyOn(i18n, 't').mockImplementation((key) => key);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -23,7 +27,7 @@ describe('usePhraseCycler', () => {
|
||||
|
||||
it('should initialize with a witty phrase when not active and not waiting', () => {
|
||||
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', () => {
|
||||
@@ -47,35 +51,30 @@ describe('usePhraseCycler', () => {
|
||||
it('should cycle through witty phrases when isActive is true and not waiting', () => {
|
||||
const { result } = renderHook(() => usePhraseCycler(true, false));
|
||||
// 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;
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
|
||||
});
|
||||
// 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;
|
||||
act(() => {
|
||||
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)', () => {
|
||||
// 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.
|
||||
let callCount = 0;
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||
// Cycle through 0, 1, 0, 1, ...
|
||||
const val = callCount % 2;
|
||||
callCount++;
|
||||
return val / WITTY_LOADING_PHRASES.length;
|
||||
return val / MOCK_WITTY_PHRASES.length;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
@@ -86,9 +85,9 @@ describe('usePhraseCycler', () => {
|
||||
// Activate
|
||||
rerender({ isActive: true, isWaiting: false });
|
||||
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.
|
||||
expect(firstActivePhrase).toBe(WITTY_LOADING_PHRASES[0]);
|
||||
expect(firstActivePhrase).toBe(MOCK_WITTY_PHRASES[0]);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
|
||||
@@ -96,18 +95,18 @@ describe('usePhraseCycler', () => {
|
||||
|
||||
// Phrase should change to the second phrase.
|
||||
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
|
||||
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)
|
||||
act(() => {
|
||||
rerender({ isActive: true, isWaiting: false });
|
||||
});
|
||||
// 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', () => {
|
||||
@@ -148,7 +147,7 @@ describe('usePhraseCycler', () => {
|
||||
|
||||
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', () => {
|
||||
@@ -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', () => {
|
||||
@@ -174,16 +173,13 @@ describe('usePhraseCycler', () => {
|
||||
);
|
||||
|
||||
const _initialPhrase = result.current;
|
||||
expect(WITTY_LOADING_PHRASES).toContain(_initialPhrase);
|
||||
expect(MOCK_WITTY_PHRASES).toContain(_initialPhrase);
|
||||
|
||||
// Cycle to a different phrase (potentially)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
|
||||
});
|
||||
if (WITTY_LOADING_PHRASES.length > 1) {
|
||||
// This check is probabilistic with random selection
|
||||
}
|
||||
expect(WITTY_LOADING_PHRASES).toContain(result.current);
|
||||
expect(MOCK_WITTY_PHRASES).toContain(result.current);
|
||||
|
||||
// Go to waiting state
|
||||
rerender({ isActive: false, isWaiting: true });
|
||||
@@ -191,6 +187,6 @@ describe('usePhraseCycler', () => {
|
||||
|
||||
// Go back to active cycling - should pick a random witty phrase
|
||||
rerender({ isActive: true, isWaiting: false });
|
||||
expect(WITTY_LOADING_PHRASES).toContain(result.current);
|
||||
expect(MOCK_WITTY_PHRASES).toContain(result.current);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,139 +5,9 @@
|
||||
*/
|
||||
|
||||
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 = [
|
||||
"I'm Feeling Lucky",
|
||||
'Shipping awesomeness... ',
|
||||
'Painting the serifs back on...',
|
||||
'Navigating the slime mold...',
|
||||
'Consulting the digital spirits...',
|
||||
'Reticulating splines...',
|
||||
'Warming up the AI hamsters...',
|
||||
'Asking the magic conch shell...',
|
||||
'Generating witty retort...',
|
||||
'Polishing the algorithms...',
|
||||
"Don't rush perfection (or my code)...",
|
||||
'Brewing fresh bytes...',
|
||||
'Counting electrons...',
|
||||
'Engaging cognitive processors...',
|
||||
'Checking for syntax errors in the universe...',
|
||||
'One moment, optimizing humor...',
|
||||
'Shuffling punchlines...',
|
||||
'Untangling neural nets...',
|
||||
'Compiling brilliance...',
|
||||
'Loading wit.exe...',
|
||||
'Summoning the cloud of wisdom...',
|
||||
'Preparing a witty response...',
|
||||
"Just a sec, I'm debugging reality...",
|
||||
'Confuzzling the options...',
|
||||
'Tuning the cosmic frequencies...',
|
||||
'Crafting a response worthy of your patience...',
|
||||
'Compiling the 1s and 0s...',
|
||||
'Resolving dependencies... and existential crises...',
|
||||
'Defragmenting memories... both RAM and personal...',
|
||||
'Rebooting the humor module...',
|
||||
'Caching the essentials (mostly cat memes)...',
|
||||
'Optimizing for ludicrous speed',
|
||||
"Swapping bits... don't tell the bytes...",
|
||||
'Garbage collecting... be right back...',
|
||||
'Assembling the interwebs...',
|
||||
'Converting coffee into code...',
|
||||
'Updating the syntax for reality...',
|
||||
'Rewiring the synapses...',
|
||||
'Looking for a misplaced semicolon...',
|
||||
"Greasin' the cogs of the machine...",
|
||||
'Pre-heating the servers...',
|
||||
'Calibrating the flux capacitor...',
|
||||
'Engaging the improbability drive...',
|
||||
'Channeling the Force...',
|
||||
'Aligning the stars for optimal response...',
|
||||
'So say we all...',
|
||||
'Loading the next great idea...',
|
||||
"Just a moment, I'm in the zone...",
|
||||
'Preparing to dazzle you with brilliance...',
|
||||
"Just a tick, I'm polishing my wit...",
|
||||
"Hold tight, I'm crafting a masterpiece...",
|
||||
"Just a jiffy, I'm debugging the universe...",
|
||||
"Just a moment, I'm aligning the pixels...",
|
||||
"Just a sec, I'm optimizing the humor...",
|
||||
"Just a moment, I'm tuning the algorithms...",
|
||||
'Warp speed engaged...',
|
||||
'Mining for more Dilithium crystals...',
|
||||
"Don't panic...",
|
||||
'Following the white rabbit...',
|
||||
'The truth is in here... somewhere...',
|
||||
'Blowing on the cartridge...',
|
||||
'Loading... Do a barrel roll!',
|
||||
'Waiting for the respawn...',
|
||||
'Finishing the Kessel Run in less than 12 parsecs...',
|
||||
"The cake is not a lie, it's just still loading...",
|
||||
'Fiddling with the character creation screen...',
|
||||
"Just a moment, I'm finding the right meme...",
|
||||
"Pressing 'A' to continue...",
|
||||
'Herding digital cats...',
|
||||
'Polishing the pixels...',
|
||||
'Finding a suitable loading screen pun...',
|
||||
'Distracting you with this witty phrase...',
|
||||
'Almost there... probably...',
|
||||
'Our hamsters are working as fast as they can...',
|
||||
'Giving Cloudy a pat on the head...',
|
||||
'Petting the cat...',
|
||||
'Rickrolling my boss...',
|
||||
'Never gonna give you up, never gonna let you down...',
|
||||
'Slapping the bass...',
|
||||
'Tasting the snozberries...',
|
||||
"I'm going the distance, I'm going for speed...",
|
||||
'Is this the real life? Is this just fantasy?...',
|
||||
"I've got a good feeling about this...",
|
||||
'Poking the bear...',
|
||||
'Doing research on the latest memes...',
|
||||
'Figuring out how to make this more witty...',
|
||||
'Hmmm... let me think...',
|
||||
'What do you call a fish with no eyes? A fsh...',
|
||||
'Why did the computer go to therapy? It had too many bytes...',
|
||||
"Why don't programmers like nature? It has too many bugs...",
|
||||
'Why do programmers prefer dark mode? Because light attracts bugs...',
|
||||
'Why did the developer go broke? Because they used up all their cache...',
|
||||
"What can you do with a broken pencil? Nothing, it's pointless...",
|
||||
'Applying percussive maintenance...',
|
||||
'Searching for the correct USB orientation...',
|
||||
'Ensuring the magic smoke stays inside the wires...',
|
||||
'Rewriting in Rust for no particular reason...',
|
||||
'Trying to exit Vim...',
|
||||
'Spinning up the hamster wheel...',
|
||||
"That's not a bug, it's an undocumented feature...",
|
||||
'Engage.',
|
||||
"I'll be back... with an answer.",
|
||||
'My other process is a TARDIS...',
|
||||
'Communing with the machine spirit...',
|
||||
'Letting the thoughts marinate...',
|
||||
'Just remembered where I put my keys...',
|
||||
'Pondering the orb...',
|
||||
"I've seen things you people wouldn't believe... like a user who reads loading messages.",
|
||||
'Initiating thoughtful gaze...',
|
||||
"What's a computer's favorite snack? Microchips.",
|
||||
"Why do Java developers wear glasses? Because they don't C#.",
|
||||
'Charging the laser... pew pew!',
|
||||
'Dividing by zero... just kidding!',
|
||||
'Looking for an adult superviso... I mean, processing.',
|
||||
'Making it go beep boop.',
|
||||
'Buffering... because even AIs need a moment.',
|
||||
'Entangling quantum particles for a faster response...',
|
||||
'Polishing the chrome... on the algorithms.',
|
||||
'Are you not entertained? (Working on it!)',
|
||||
'Summoning the code gremlins... to help, of course.',
|
||||
'Just waiting for the dial-up tone to finish...',
|
||||
'Recalibrating the humor-o-meter.',
|
||||
'My other loading screen is even funnier.',
|
||||
"Pretty sure there's a cat walking on the keyboard somewhere...",
|
||||
'Enhancing... Enhancing... Still loading.',
|
||||
"It's not a bug, it's a feature... of this loading screen.",
|
||||
'Have you tried turning it off and on again? (The loading screen, not me.)',
|
||||
'Constructing additional pylons...',
|
||||
'New line? That’s Ctrl+J.',
|
||||
];
|
||||
export const WITTY_LOADING_PHRASES: string[] = ["I'm Feeling Lucky"];
|
||||
|
||||
export const PHRASE_CHANGE_INTERVAL_MS = 15000;
|
||||
|
||||
@@ -152,14 +22,16 @@ export const usePhraseCycler = (
|
||||
isWaiting: boolean,
|
||||
customPhrases?: string[],
|
||||
) => {
|
||||
// Translate all phrases at once if using default phrases
|
||||
const loadingPhrases = useMemo(
|
||||
() =>
|
||||
customPhrases && customPhrases.length > 0
|
||||
? customPhrases
|
||||
: WITTY_LOADING_PHRASES.map((phrase) => t(phrase)),
|
||||
[customPhrases],
|
||||
);
|
||||
// Get phrases from translations if available
|
||||
const loadingPhrases = useMemo(() => {
|
||||
if (customPhrases && customPhrases.length > 0) {
|
||||
return customPhrases;
|
||||
}
|
||||
const translatedPhrases = ta('WITTY_LOADING_PHRASES');
|
||||
return translatedPhrases.length > 0
|
||||
? translatedPhrases
|
||||
: WITTY_LOADING_PHRASES;
|
||||
}, [customPhrases]);
|
||||
|
||||
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState(
|
||||
loadingPhrases[0],
|
||||
|
||||
@@ -54,7 +54,6 @@ import { canUseRipgrep } from '../utils/ripgrepUtils.js';
|
||||
import { RipGrepTool } from '../tools/ripGrep.js';
|
||||
import { ShellTool } from '../tools/shell.js';
|
||||
import { SmartEditTool } from '../tools/smart-edit.js';
|
||||
import { SkillTool } from '../tools/skill.js';
|
||||
import { TaskTool } from '../tools/task.js';
|
||||
import { TodoWriteTool } from '../tools/todoWrite.js';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
@@ -66,7 +65,6 @@ import { WriteFileTool } from '../tools/write-file.js';
|
||||
import { ideContextStore } from '../ide/ideContext.js';
|
||||
import { InputFormat, OutputFormat } from '../output/types.js';
|
||||
import { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import { SkillManager } from '../skills/skill-manager.js';
|
||||
import { SubagentManager } from '../subagents/subagent-manager.js';
|
||||
import type { SubagentConfig } from '../subagents/types.js';
|
||||
import {
|
||||
@@ -307,7 +305,6 @@ export interface ConfigParameters {
|
||||
extensionContextFilePaths?: string[];
|
||||
maxSessionTurns?: number;
|
||||
sessionTokenLimit?: number;
|
||||
experimentalSkills?: boolean;
|
||||
experimentalZedIntegration?: boolean;
|
||||
listExtensions?: boolean;
|
||||
extensions?: GeminiCLIExtension[];
|
||||
@@ -392,7 +389,6 @@ export class Config {
|
||||
private toolRegistry!: ToolRegistry;
|
||||
private promptRegistry!: PromptRegistry;
|
||||
private subagentManager!: SubagentManager;
|
||||
private skillManager!: SkillManager;
|
||||
private fileSystemService: FileSystemService;
|
||||
private contentGeneratorConfig!: ContentGeneratorConfig;
|
||||
private contentGenerator!: ContentGenerator;
|
||||
@@ -462,7 +458,6 @@ export class Config {
|
||||
| undefined;
|
||||
private readonly cliVersion?: string;
|
||||
private readonly experimentalZedIntegration: boolean = false;
|
||||
private readonly experimentalSkills: boolean = false;
|
||||
private readonly chatRecordingEnabled: boolean;
|
||||
private readonly loadMemoryFromIncludeDirectories: boolean = false;
|
||||
private readonly webSearch?: {
|
||||
@@ -562,7 +557,6 @@ export class Config {
|
||||
this.sessionTokenLimit = params.sessionTokenLimit ?? -1;
|
||||
this.experimentalZedIntegration =
|
||||
params.experimentalZedIntegration ?? false;
|
||||
this.experimentalSkills = params.experimentalSkills ?? false;
|
||||
this.listExtensions = params.listExtensions ?? false;
|
||||
this._extensions = params.extensions ?? [];
|
||||
this._blockedMcpServers = params.blockedMcpServers ?? [];
|
||||
@@ -650,7 +644,6 @@ export class Config {
|
||||
}
|
||||
this.promptRegistry = new PromptRegistry();
|
||||
this.subagentManager = new SubagentManager(this);
|
||||
this.skillManager = new SkillManager(this);
|
||||
|
||||
// Load session subagents if they were provided before initialization
|
||||
if (this.sessionSubagents.length > 0) {
|
||||
@@ -1083,10 +1076,6 @@ export class Config {
|
||||
return this.experimentalZedIntegration;
|
||||
}
|
||||
|
||||
getExperimentalSkills(): boolean {
|
||||
return this.experimentalSkills;
|
||||
}
|
||||
|
||||
getListExtensions(): boolean {
|
||||
return this.listExtensions;
|
||||
}
|
||||
@@ -1317,10 +1306,6 @@ export class Config {
|
||||
return this.subagentManager;
|
||||
}
|
||||
|
||||
getSkillManager(): SkillManager {
|
||||
return this.skillManager;
|
||||
}
|
||||
|
||||
async createToolRegistry(
|
||||
sendSdkMcpMessage?: SendSdkMcpMessage,
|
||||
): Promise<ToolRegistry> {
|
||||
@@ -1363,9 +1348,6 @@ export class Config {
|
||||
};
|
||||
|
||||
registerCoreTool(TaskTool, this);
|
||||
if (this.getExperimentalSkills()) {
|
||||
registerCoreTool(SkillTool, this);
|
||||
}
|
||||
registerCoreTool(LSTool, this);
|
||||
registerCoreTool(ReadFileTool, this);
|
||||
|
||||
|
||||
@@ -126,10 +126,6 @@ export class Storage {
|
||||
return path.join(this.getExtensionsDir(), 'qwen-extension.json');
|
||||
}
|
||||
|
||||
getUserSkillsDir(): string {
|
||||
return path.join(Storage.getGlobalQwenDir(), 'skills');
|
||||
}
|
||||
|
||||
getHistoryFilePath(): string {
|
||||
return path.join(this.getProjectTempDir(), 'shell_history');
|
||||
}
|
||||
|
||||
@@ -85,9 +85,6 @@ export * from './tools/tool-registry.js';
|
||||
// Export subagents (Phase 1)
|
||||
export * from './subagents/index.js';
|
||||
|
||||
// Export skills
|
||||
export * from './skills/index.js';
|
||||
|
||||
// Export prompt logic
|
||||
export * from './prompts/mcp-prompts.js';
|
||||
|
||||
@@ -109,7 +106,6 @@ export * from './tools/mcp-client-manager.js';
|
||||
export * from './tools/mcp-tool.js';
|
||||
export * from './tools/sdk-control-client-transport.js';
|
||||
export * from './tools/task.js';
|
||||
export * from './tools/skill.js';
|
||||
export * from './tools/todoWrite.js';
|
||||
export * from './tools/exitPlanMode.js';
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Skills feature implementation
|
||||
*
|
||||
* This module provides the foundation for the skills feature, which allows
|
||||
* users to define reusable skill configurations that can be loaded by the
|
||||
* model via a dedicated Skills tool.
|
||||
*
|
||||
* Skills are stored as directories in `.qwen/skills/` (project-level) or
|
||||
* `~/.qwen/skills/` (user-level), with each directory containing a SKILL.md
|
||||
* file with YAML frontmatter for metadata.
|
||||
*/
|
||||
|
||||
// Core types and interfaces
|
||||
export type {
|
||||
SkillConfig,
|
||||
SkillLevel,
|
||||
SkillValidationResult,
|
||||
ListSkillsOptions,
|
||||
SkillErrorCode,
|
||||
} from './types.js';
|
||||
|
||||
export { SkillError } from './types.js';
|
||||
|
||||
// Main management class
|
||||
export { SkillManager } from './skill-manager.js';
|
||||
@@ -1,463 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { SkillManager } from './skill-manager.js';
|
||||
import { type SkillConfig, SkillError } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
|
||||
// Mock file system operations
|
||||
vi.mock('fs/promises');
|
||||
vi.mock('os');
|
||||
|
||||
// Mock yaml parser - use vi.hoisted for proper hoisting
|
||||
const mockParseYaml = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../utils/yaml-parser.js', () => ({
|
||||
parse: mockParseYaml,
|
||||
stringify: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('SkillManager', () => {
|
||||
let manager: SkillManager;
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock Config object using test utility
|
||||
mockConfig = makeFakeConfig({});
|
||||
|
||||
// Mock the project root method
|
||||
vi.spyOn(mockConfig, 'getProjectRoot').mockReturnValue('/test/project');
|
||||
|
||||
// Mock os.homedir
|
||||
vi.mocked(os.homedir).mockReturnValue('/home/user');
|
||||
|
||||
// Reset and setup mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup yaml parser mocks with sophisticated behavior
|
||||
mockParseYaml.mockImplementation((yamlString: string) => {
|
||||
// Handle different test cases based on YAML content
|
||||
if (yamlString.includes('allowedTools:')) {
|
||||
return {
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
allowedTools: ['read_file', 'write_file'],
|
||||
};
|
||||
}
|
||||
if (yamlString.includes('name: skill1')) {
|
||||
return { name: 'skill1', description: 'First skill' };
|
||||
}
|
||||
if (yamlString.includes('name: skill2')) {
|
||||
return { name: 'skill2', description: 'Second skill' };
|
||||
}
|
||||
if (yamlString.includes('name: skill3')) {
|
||||
return { name: 'skill3', description: 'Third skill' };
|
||||
}
|
||||
if (!yamlString.includes('name:')) {
|
||||
return { description: 'A test skill' }; // Missing name case
|
||||
}
|
||||
if (!yamlString.includes('description:')) {
|
||||
return { name: 'test-skill' }; // Missing description case
|
||||
}
|
||||
// Default case
|
||||
return {
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
};
|
||||
});
|
||||
|
||||
manager = new SkillManager(mockConfig);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const validSkillConfig: SkillConfig = {
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
level: 'project',
|
||||
filePath: '/test/project/.qwen/skills/test-skill/SKILL.md',
|
||||
body: 'You are a helpful assistant with this skill.',
|
||||
};
|
||||
|
||||
const validMarkdown = `---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
You are a helpful assistant with this skill.
|
||||
`;
|
||||
|
||||
describe('parseSkillContent', () => {
|
||||
it('should parse valid markdown content', () => {
|
||||
const config = manager.parseSkillContent(
|
||||
validMarkdown,
|
||||
validSkillConfig.filePath,
|
||||
'project',
|
||||
);
|
||||
|
||||
expect(config.name).toBe('test-skill');
|
||||
expect(config.description).toBe('A test skill');
|
||||
expect(config.body).toBe('You are a helpful assistant with this skill.');
|
||||
expect(config.level).toBe('project');
|
||||
expect(config.filePath).toBe(validSkillConfig.filePath);
|
||||
});
|
||||
|
||||
it('should parse content with allowedTools', () => {
|
||||
const markdownWithTools = `---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
allowedTools:
|
||||
- read_file
|
||||
- write_file
|
||||
---
|
||||
|
||||
You are a helpful assistant with this skill.
|
||||
`;
|
||||
|
||||
const config = manager.parseSkillContent(
|
||||
markdownWithTools,
|
||||
validSkillConfig.filePath,
|
||||
'project',
|
||||
);
|
||||
|
||||
expect(config.allowedTools).toEqual(['read_file', 'write_file']);
|
||||
});
|
||||
|
||||
it('should determine level from file path', () => {
|
||||
const projectPath = '/test/project/.qwen/skills/test-skill/SKILL.md';
|
||||
const userPath = '/home/user/.qwen/skills/test-skill/SKILL.md';
|
||||
|
||||
const projectConfig = manager.parseSkillContent(
|
||||
validMarkdown,
|
||||
projectPath,
|
||||
'project',
|
||||
);
|
||||
const userConfig = manager.parseSkillContent(
|
||||
validMarkdown,
|
||||
userPath,
|
||||
'user',
|
||||
);
|
||||
|
||||
expect(projectConfig.level).toBe('project');
|
||||
expect(userConfig.level).toBe('user');
|
||||
});
|
||||
|
||||
it('should throw error for invalid frontmatter format', () => {
|
||||
const invalidMarkdown = `No frontmatter here
|
||||
Just content`;
|
||||
|
||||
expect(() =>
|
||||
manager.parseSkillContent(
|
||||
invalidMarkdown,
|
||||
validSkillConfig.filePath,
|
||||
'project',
|
||||
),
|
||||
).toThrow(SkillError);
|
||||
});
|
||||
|
||||
it('should throw error for missing name', () => {
|
||||
const markdownWithoutName = `---
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
You are a helpful assistant.
|
||||
`;
|
||||
|
||||
expect(() =>
|
||||
manager.parseSkillContent(
|
||||
markdownWithoutName,
|
||||
validSkillConfig.filePath,
|
||||
'project',
|
||||
),
|
||||
).toThrow(SkillError);
|
||||
});
|
||||
|
||||
it('should throw error for missing description', () => {
|
||||
const markdownWithoutDescription = `---
|
||||
name: test-skill
|
||||
---
|
||||
|
||||
You are a helpful assistant.
|
||||
`;
|
||||
|
||||
expect(() =>
|
||||
manager.parseSkillContent(
|
||||
markdownWithoutDescription,
|
||||
validSkillConfig.filePath,
|
||||
'project',
|
||||
),
|
||||
).toThrow(SkillError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConfig', () => {
|
||||
it('should validate valid configuration', () => {
|
||||
const result = manager.validateConfig(validSkillConfig);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should report error for missing name', () => {
|
||||
const invalidConfig = { ...validSkillConfig, name: '' };
|
||||
const result = manager.validateConfig(invalidConfig);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('"name" cannot be empty');
|
||||
});
|
||||
|
||||
it('should report error for missing description', () => {
|
||||
const invalidConfig = { ...validSkillConfig, description: '' };
|
||||
const result = manager.validateConfig(invalidConfig);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('"description" cannot be empty');
|
||||
});
|
||||
|
||||
it('should report error for invalid allowedTools type', () => {
|
||||
const invalidConfig = {
|
||||
...validSkillConfig,
|
||||
allowedTools: 'not-an-array' as unknown as string[],
|
||||
};
|
||||
const result = manager.validateConfig(invalidConfig);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('"allowedTools" must be an array');
|
||||
});
|
||||
|
||||
it('should warn for empty body', () => {
|
||||
const configWithEmptyBody = { ...validSkillConfig, body: '' };
|
||||
const result = manager.validateConfig(configWithEmptyBody);
|
||||
|
||||
expect(result.isValid).toBe(true); // Still valid
|
||||
expect(result.warnings).toContain('Skill body is empty');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSkill', () => {
|
||||
it('should load skill from project level first', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'test-skill', isDirectory: () => true, isFile: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readFile).mockResolvedValue(validMarkdown);
|
||||
|
||||
const config = await manager.loadSkill('test-skill');
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config!.name).toBe('test-skill');
|
||||
});
|
||||
|
||||
it('should fall back to user level if project level fails', async () => {
|
||||
vi.mocked(fs.readdir)
|
||||
.mockRejectedValueOnce(new Error('Project dir not found')) // project level fails
|
||||
.mockResolvedValueOnce([
|
||||
{ name: 'test-skill', isDirectory: () => true, isFile: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>); // user level succeeds
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readFile).mockResolvedValue(validMarkdown);
|
||||
|
||||
const config = await manager.loadSkill('test-skill');
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config!.name).toBe('test-skill');
|
||||
});
|
||||
|
||||
it('should return null if not found at either level', async () => {
|
||||
vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found'));
|
||||
|
||||
const config = await manager.loadSkill('nonexistent');
|
||||
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSkillForRuntime', () => {
|
||||
it('should load skill for runtime', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValueOnce([
|
||||
{ name: 'test-skill', isDirectory: () => true, isFile: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readFile).mockResolvedValue(validMarkdown); // SKILL.md
|
||||
|
||||
const config = await manager.loadSkillForRuntime('test-skill');
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config!.name).toBe('test-skill');
|
||||
});
|
||||
|
||||
it('should return null if skill not found', async () => {
|
||||
vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found'));
|
||||
|
||||
const config = await manager.loadSkillForRuntime('nonexistent');
|
||||
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSkills', () => {
|
||||
beforeEach(() => {
|
||||
// Mock directory listing for skills directories (with Dirent objects)
|
||||
vi.mocked(fs.readdir)
|
||||
.mockResolvedValueOnce([
|
||||
{ name: 'skill1', isDirectory: () => true, isFile: () => false },
|
||||
{ name: 'skill2', isDirectory: () => true, isFile: () => false },
|
||||
{
|
||||
name: 'not-a-dir.txt',
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
},
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>)
|
||||
.mockResolvedValueOnce([
|
||||
{ name: 'skill3', isDirectory: () => true, isFile: () => false },
|
||||
{ name: 'skill1', isDirectory: () => true, isFile: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
|
||||
// Mock file reading for valid skills
|
||||
vi.mocked(fs.readFile).mockImplementation((filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('skill1')) {
|
||||
return Promise.resolve(`---
|
||||
name: skill1
|
||||
description: First skill
|
||||
---
|
||||
Skill 1 content`);
|
||||
} else if (pathStr.includes('skill2')) {
|
||||
return Promise.resolve(`---
|
||||
name: skill2
|
||||
description: Second skill
|
||||
---
|
||||
Skill 2 content`);
|
||||
} else if (pathStr.includes('skill3')) {
|
||||
return Promise.resolve(`---
|
||||
name: skill3
|
||||
description: Third skill
|
||||
---
|
||||
Skill 3 content`);
|
||||
}
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
});
|
||||
|
||||
it('should list skills from both levels', async () => {
|
||||
const skills = await manager.listSkills();
|
||||
|
||||
expect(skills).toHaveLength(3); // skill1 (project takes precedence), skill2, skill3
|
||||
expect(skills.map((s) => s.name).sort()).toEqual([
|
||||
'skill1',
|
||||
'skill2',
|
||||
'skill3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should prioritize project level over user level', async () => {
|
||||
const skills = await manager.listSkills();
|
||||
const skill1 = skills.find((s) => s.name === 'skill1');
|
||||
|
||||
expect(skill1!.level).toBe('project');
|
||||
});
|
||||
|
||||
it('should filter by level', async () => {
|
||||
const projectSkills = await manager.listSkills({
|
||||
level: 'project',
|
||||
});
|
||||
|
||||
expect(projectSkills).toHaveLength(2); // skill1, skill2
|
||||
expect(projectSkills.every((s) => s.level === 'project')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty directories', async () => {
|
||||
vi.mocked(fs.readdir).mockReset();
|
||||
vi.mocked(fs.readdir).mockResolvedValue(
|
||||
[] as unknown as Awaited<ReturnType<typeof fs.readdir>>,
|
||||
);
|
||||
|
||||
const skills = await manager.listSkills({ force: true });
|
||||
|
||||
expect(skills).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle directory read errors', async () => {
|
||||
vi.mocked(fs.readdir).mockReset();
|
||||
vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found'));
|
||||
|
||||
const skills = await manager.listSkills({ force: true });
|
||||
|
||||
expect(skills).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSkillsBaseDir', () => {
|
||||
it('should return project-level base dir', () => {
|
||||
const baseDir = manager.getSkillsBaseDir('project');
|
||||
|
||||
expect(baseDir).toBe(path.join('/test/project', '.qwen', 'skills'));
|
||||
});
|
||||
|
||||
it('should return user-level base dir', () => {
|
||||
const baseDir = manager.getSkillsBaseDir('user');
|
||||
|
||||
expect(baseDir).toBe(path.join('/home/user', '.qwen', 'skills'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('change listeners', () => {
|
||||
it('should notify listeners when cache is refreshed', async () => {
|
||||
const listener = vi.fn();
|
||||
manager.addChangeListener(listener);
|
||||
|
||||
vi.mocked(fs.readdir).mockResolvedValue(
|
||||
[] as unknown as Awaited<ReturnType<typeof fs.readdir>>,
|
||||
);
|
||||
|
||||
await manager.refreshCache();
|
||||
|
||||
expect(listener).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove listener when cleanup function is called', async () => {
|
||||
const listener = vi.fn();
|
||||
const removeListener = manager.addChangeListener(listener);
|
||||
|
||||
removeListener();
|
||||
|
||||
vi.mocked(fs.readdir).mockResolvedValue(
|
||||
[] as unknown as Awaited<ReturnType<typeof fs.readdir>>,
|
||||
);
|
||||
|
||||
await manager.refreshCache();
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse errors', () => {
|
||||
it('should track parse errors', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'bad-skill', isDirectory: () => true, isFile: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
'invalid content without frontmatter',
|
||||
);
|
||||
|
||||
await manager.listSkills({ force: true });
|
||||
|
||||
const errors = manager.getParseErrors();
|
||||
expect(errors.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,452 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { parse as parseYaml } from '../utils/yaml-parser.js';
|
||||
import type {
|
||||
SkillConfig,
|
||||
SkillLevel,
|
||||
ListSkillsOptions,
|
||||
SkillValidationResult,
|
||||
} from './types.js';
|
||||
import { SkillError, SkillErrorCode } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
const QWEN_CONFIG_DIR = '.qwen';
|
||||
const SKILLS_CONFIG_DIR = 'skills';
|
||||
const SKILL_MANIFEST_FILE = 'SKILL.md';
|
||||
|
||||
/**
|
||||
* Manages skill configurations stored as directories containing SKILL.md files.
|
||||
* Provides discovery, parsing, validation, and caching for skills.
|
||||
*/
|
||||
export class SkillManager {
|
||||
private skillsCache: Map<SkillLevel, SkillConfig[]> | null = null;
|
||||
private readonly changeListeners: Set<() => void> = new Set();
|
||||
private parseErrors: Map<string, SkillError> = new Map();
|
||||
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
/**
|
||||
* Adds a listener that will be called when skills change.
|
||||
* @returns A function to remove the listener.
|
||||
*/
|
||||
addChangeListener(listener: () => void): () => void {
|
||||
this.changeListeners.add(listener);
|
||||
return () => {
|
||||
this.changeListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies all registered change listeners.
|
||||
*/
|
||||
private notifyChangeListeners(): void {
|
||||
for (const listener of this.changeListeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch (error) {
|
||||
console.warn('Skill change listener threw an error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets any parse errors that occurred during skill loading.
|
||||
* @returns Map of skill paths to their parse errors.
|
||||
*/
|
||||
getParseErrors(): Map<string, SkillError> {
|
||||
return new Map(this.parseErrors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all available skills.
|
||||
*
|
||||
* @param options - Filtering options
|
||||
* @returns Array of skill configurations
|
||||
*/
|
||||
async listSkills(options: ListSkillsOptions = {}): Promise<SkillConfig[]> {
|
||||
const skills: SkillConfig[] = [];
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
const levelsToCheck: SkillLevel[] = options.level
|
||||
? [options.level]
|
||||
: ['project', 'user'];
|
||||
|
||||
// Check if we should use cache or force refresh
|
||||
const shouldUseCache = !options.force && this.skillsCache !== null;
|
||||
|
||||
// Initialize cache if it doesn't exist or we're forcing a refresh
|
||||
if (!shouldUseCache) {
|
||||
await this.refreshCache();
|
||||
}
|
||||
|
||||
// Collect skills from each level (project takes precedence over user)
|
||||
for (const level of levelsToCheck) {
|
||||
const levelSkills = this.skillsCache?.get(level) || [];
|
||||
|
||||
for (const skill of levelSkills) {
|
||||
// Skip if we've already seen this name (precedence: project > user)
|
||||
if (seenNames.has(skill.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
skills.push(skill);
|
||||
seenNames.add(skill.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by name for consistent ordering
|
||||
skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a skill configuration by name.
|
||||
* If level is specified, only searches that level.
|
||||
* If level is omitted, searches project-level first, then user-level.
|
||||
*
|
||||
* @param name - Name of the skill to load
|
||||
* @param level - Optional level to limit search to
|
||||
* @returns SkillConfig or null if not found
|
||||
*/
|
||||
async loadSkill(
|
||||
name: string,
|
||||
level?: SkillLevel,
|
||||
): Promise<SkillConfig | null> {
|
||||
if (level) {
|
||||
return this.findSkillByNameAtLevel(name, level);
|
||||
}
|
||||
|
||||
// Try project level first
|
||||
const projectSkill = await this.findSkillByNameAtLevel(name, 'project');
|
||||
if (projectSkill) {
|
||||
return projectSkill;
|
||||
}
|
||||
|
||||
// Try user level
|
||||
return this.findSkillByNameAtLevel(name, 'user');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a skill with its full content, ready for runtime use.
|
||||
* This includes loading additional files from the skill directory.
|
||||
*
|
||||
* @param name - Name of the skill to load
|
||||
* @param level - Optional level to limit search to
|
||||
* @returns SkillConfig or null if not found
|
||||
*/
|
||||
async loadSkillForRuntime(
|
||||
name: string,
|
||||
level?: SkillLevel,
|
||||
): Promise<SkillConfig | null> {
|
||||
const skill = await this.loadSkill(name, level);
|
||||
if (!skill) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a skill configuration.
|
||||
*
|
||||
* @param config - Configuration to validate
|
||||
* @returns Validation result
|
||||
*/
|
||||
validateConfig(config: Partial<SkillConfig>): SkillValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check required fields
|
||||
if (typeof config.name !== 'string') {
|
||||
errors.push('Missing or invalid "name" field');
|
||||
} else if (config.name.trim() === '') {
|
||||
errors.push('"name" cannot be empty');
|
||||
}
|
||||
|
||||
if (typeof config.description !== 'string') {
|
||||
errors.push('Missing or invalid "description" field');
|
||||
} else if (config.description.trim() === '') {
|
||||
errors.push('"description" cannot be empty');
|
||||
}
|
||||
|
||||
// Validate allowedTools if present
|
||||
if (config.allowedTools !== undefined) {
|
||||
if (!Array.isArray(config.allowedTools)) {
|
||||
errors.push('"allowedTools" must be an array');
|
||||
} else {
|
||||
for (const tool of config.allowedTools) {
|
||||
if (typeof tool !== 'string') {
|
||||
errors.push('"allowedTools" must contain only strings');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if body is empty
|
||||
if (!config.body || config.body.trim() === '') {
|
||||
warnings.push('Skill body is empty');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the skills cache by loading all skills from disk.
|
||||
*/
|
||||
async refreshCache(): Promise<void> {
|
||||
const skillsCache = new Map<SkillLevel, SkillConfig[]>();
|
||||
this.parseErrors.clear();
|
||||
|
||||
const levels: SkillLevel[] = ['project', 'user'];
|
||||
|
||||
for (const level of levels) {
|
||||
const levelSkills = await this.listSkillsAtLevel(level);
|
||||
skillsCache.set(level, levelSkills);
|
||||
}
|
||||
|
||||
this.skillsCache = skillsCache;
|
||||
this.notifyChangeListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a SKILL.md file and returns the configuration.
|
||||
*
|
||||
* @param filePath - Path to the SKILL.md file
|
||||
* @param level - Storage level
|
||||
* @returns SkillConfig
|
||||
* @throws SkillError if parsing fails
|
||||
*/
|
||||
parseSkillFile(filePath: string, level: SkillLevel): Promise<SkillConfig> {
|
||||
return this.parseSkillFileInternal(filePath, level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation of skill file parsing.
|
||||
*/
|
||||
private async parseSkillFileInternal(
|
||||
filePath: string,
|
||||
level: SkillLevel,
|
||||
): Promise<SkillConfig> {
|
||||
let content: string;
|
||||
|
||||
try {
|
||||
content = await fs.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
const skillError = new SkillError(
|
||||
`Failed to read skill file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
SkillErrorCode.FILE_ERROR,
|
||||
);
|
||||
this.parseErrors.set(filePath, skillError);
|
||||
throw skillError;
|
||||
}
|
||||
|
||||
return this.parseSkillContent(content, filePath, level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses skill content from a string.
|
||||
*
|
||||
* @param content - File content
|
||||
* @param filePath - File path for error reporting
|
||||
* @param level - Storage level
|
||||
* @returns SkillConfig
|
||||
* @throws SkillError if parsing fails
|
||||
*/
|
||||
parseSkillContent(
|
||||
content: string,
|
||||
filePath: string,
|
||||
level: SkillLevel,
|
||||
): SkillConfig {
|
||||
try {
|
||||
// Split frontmatter and content
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('Invalid format: missing YAML frontmatter');
|
||||
}
|
||||
|
||||
const [, frontmatterYaml, body] = match;
|
||||
|
||||
// Parse YAML frontmatter
|
||||
const frontmatter = parseYaml(frontmatterYaml) as Record<string, unknown>;
|
||||
|
||||
// Extract required fields
|
||||
const nameRaw = frontmatter['name'];
|
||||
const descriptionRaw = frontmatter['description'];
|
||||
|
||||
if (nameRaw == null || nameRaw === '') {
|
||||
throw new Error('Missing "name" in frontmatter');
|
||||
}
|
||||
|
||||
if (descriptionRaw == null || descriptionRaw === '') {
|
||||
throw new Error('Missing "description" in frontmatter');
|
||||
}
|
||||
|
||||
// Convert to strings
|
||||
const name = String(nameRaw);
|
||||
const description = String(descriptionRaw);
|
||||
|
||||
// Extract optional fields
|
||||
const allowedToolsRaw = frontmatter['allowedTools'] as
|
||||
| unknown[]
|
||||
| undefined;
|
||||
let allowedTools: string[] | undefined;
|
||||
|
||||
if (allowedToolsRaw !== undefined) {
|
||||
if (Array.isArray(allowedToolsRaw)) {
|
||||
allowedTools = allowedToolsRaw.map(String);
|
||||
} else {
|
||||
throw new Error('"allowedTools" must be an array');
|
||||
}
|
||||
}
|
||||
|
||||
const config: SkillConfig = {
|
||||
name,
|
||||
description,
|
||||
allowedTools,
|
||||
level,
|
||||
filePath,
|
||||
body: body.trim(),
|
||||
};
|
||||
|
||||
// Validate the parsed configuration
|
||||
const validation = this.validateConfig(config);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
const skillError = new SkillError(
|
||||
`Failed to parse skill file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
SkillErrorCode.PARSE_ERROR,
|
||||
);
|
||||
this.parseErrors.set(filePath, skillError);
|
||||
throw skillError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the base directory for skills at a specific level.
|
||||
*
|
||||
* @param level - Storage level
|
||||
* @returns Absolute directory path
|
||||
*/
|
||||
getSkillsBaseDir(level: SkillLevel): string {
|
||||
const baseDir =
|
||||
level === 'project'
|
||||
? path.join(
|
||||
this.config.getProjectRoot(),
|
||||
QWEN_CONFIG_DIR,
|
||||
SKILLS_CONFIG_DIR,
|
||||
)
|
||||
: path.join(os.homedir(), QWEN_CONFIG_DIR, SKILLS_CONFIG_DIR);
|
||||
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists skills at a specific level.
|
||||
*
|
||||
* @param level - Storage level to scan
|
||||
* @returns Array of skill configurations
|
||||
*/
|
||||
private async listSkillsAtLevel(level: SkillLevel): Promise<SkillConfig[]> {
|
||||
const projectRoot = this.config.getProjectRoot();
|
||||
const homeDir = os.homedir();
|
||||
const isHomeDirectory = path.resolve(projectRoot) === path.resolve(homeDir);
|
||||
|
||||
// If project level is requested but project root is same as home directory,
|
||||
// return empty array to avoid conflicts between project and global skills
|
||||
if (level === 'project' && isHomeDirectory) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const baseDir = this.getSkillsBaseDir(level);
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(baseDir, { withFileTypes: true });
|
||||
const skills: SkillConfig[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
// Only process directories (each skill is a directory)
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const skillDir = path.join(baseDir, entry.name);
|
||||
const skillManifest = path.join(skillDir, SKILL_MANIFEST_FILE);
|
||||
|
||||
try {
|
||||
// Check if SKILL.md exists
|
||||
await fs.access(skillManifest);
|
||||
|
||||
const config = await this.parseSkillFileInternal(
|
||||
skillManifest,
|
||||
level,
|
||||
);
|
||||
skills.push(config);
|
||||
} catch (error) {
|
||||
// Skip directories without valid SKILL.md
|
||||
if (error instanceof SkillError) {
|
||||
// Parse error was already recorded
|
||||
console.warn(
|
||||
`Failed to parse skill at ${skillDir}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
} catch (_error) {
|
||||
// Directory doesn't exist or can't be read
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a skill by name at a specific level.
|
||||
*
|
||||
* @param name - Name of the skill to find
|
||||
* @param level - Storage level to search
|
||||
* @returns SkillConfig or null if not found
|
||||
*/
|
||||
private async findSkillByNameAtLevel(
|
||||
name: string,
|
||||
level: SkillLevel,
|
||||
): Promise<SkillConfig | null> {
|
||||
await this.ensureLevelCache(level);
|
||||
|
||||
const levelSkills = this.skillsCache?.get(level) || [];
|
||||
|
||||
// Find the skill with matching name
|
||||
return levelSkills.find((skill) => skill.name === name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the cache is populated for a specific level without loading other levels.
|
||||
*/
|
||||
private async ensureLevelCache(level: SkillLevel): Promise<void> {
|
||||
if (!this.skillsCache) {
|
||||
this.skillsCache = new Map<SkillLevel, SkillConfig[]>();
|
||||
}
|
||||
|
||||
if (!this.skillsCache.has(level)) {
|
||||
const levelSkills = await this.listSkillsAtLevel(level);
|
||||
this.skillsCache.set(level, levelSkills);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents the storage level for a skill configuration.
|
||||
* - 'project': Stored in `.qwen/skills/` within the project directory
|
||||
* - 'user': Stored in `~/.qwen/skills/` in the user's home directory
|
||||
*/
|
||||
export type SkillLevel = 'project' | 'user';
|
||||
|
||||
/**
|
||||
* Core configuration for a skill as stored in SKILL.md files.
|
||||
* Each skill directory contains a SKILL.md file with YAML frontmatter
|
||||
* containing metadata, followed by markdown content describing the skill.
|
||||
*/
|
||||
export interface SkillConfig {
|
||||
/** Unique name identifier for the skill */
|
||||
name: string;
|
||||
|
||||
/** Human-readable description of what this skill provides */
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* Optional list of tool names that this skill is allowed to use.
|
||||
* For v1, this is informational only (no gating).
|
||||
*/
|
||||
allowedTools?: string[];
|
||||
|
||||
/**
|
||||
* Storage level - determines where the configuration file is stored
|
||||
*/
|
||||
level: SkillLevel;
|
||||
|
||||
/**
|
||||
* Absolute path to the skill directory containing SKILL.md
|
||||
*/
|
||||
filePath: string;
|
||||
|
||||
/**
|
||||
* The markdown body content from SKILL.md (after the frontmatter)
|
||||
*/
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime configuration for a skill when it's being actively used.
|
||||
* Extends SkillConfig with additional runtime-specific fields.
|
||||
*/
|
||||
export type SkillRuntimeConfig = SkillConfig;
|
||||
|
||||
/**
|
||||
* Result of a validation operation on a skill configuration.
|
||||
*/
|
||||
export interface SkillValidationResult {
|
||||
/** Whether the configuration is valid */
|
||||
isValid: boolean;
|
||||
|
||||
/** Array of error messages if validation failed */
|
||||
errors: string[];
|
||||
|
||||
/** Array of warning messages (non-blocking issues) */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for listing skills.
|
||||
*/
|
||||
export interface ListSkillsOptions {
|
||||
/** Filter by storage level */
|
||||
level?: SkillLevel;
|
||||
|
||||
/** Force refresh from disk, bypassing cache. Defaults to false. */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a skill operation fails.
|
||||
*/
|
||||
export class SkillError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: SkillErrorCode,
|
||||
readonly skillName?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SkillError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error codes for skill operations.
|
||||
*/
|
||||
export const SkillErrorCode = {
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
INVALID_CONFIG: 'INVALID_CONFIG',
|
||||
INVALID_NAME: 'INVALID_NAME',
|
||||
FILE_ERROR: 'FILE_ERROR',
|
||||
PARSE_ERROR: 'PARSE_ERROR',
|
||||
} as const;
|
||||
|
||||
export type SkillErrorCode =
|
||||
(typeof SkillErrorCode)[keyof typeof SkillErrorCode];
|
||||
@@ -33,7 +33,6 @@ export const EVENT_MALFORMED_JSON_RESPONSE =
|
||||
export const EVENT_FILE_OPERATION = 'qwen-code.file_operation';
|
||||
export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
|
||||
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
|
||||
export const EVENT_SKILL_LAUNCH = 'qwen-code.skill_launch';
|
||||
export const EVENT_AUTH = 'qwen-code.auth';
|
||||
|
||||
// Performance Events
|
||||
|
||||
@@ -44,7 +44,6 @@ export {
|
||||
logRipgrepFallback,
|
||||
logNextSpeakerCheck,
|
||||
logAuth,
|
||||
logSkillLaunch,
|
||||
} from './loggers.js';
|
||||
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
|
||||
export {
|
||||
@@ -64,7 +63,6 @@ export {
|
||||
RipgrepFallbackEvent,
|
||||
NextSpeakerCheckEvent,
|
||||
AuthEvent,
|
||||
SkillLaunchEvent,
|
||||
} from './types.js';
|
||||
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
|
||||
export type { TelemetryEvent } from './types.js';
|
||||
|
||||
@@ -206,8 +206,6 @@ describe('loggers', () => {
|
||||
mcp_tools: undefined,
|
||||
mcp_tools_count: undefined,
|
||||
output_format: 'json',
|
||||
skills: undefined,
|
||||
subagents: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
EVENT_MALFORMED_JSON_RESPONSE,
|
||||
EVENT_INVALID_CHUNK,
|
||||
EVENT_AUTH,
|
||||
EVENT_SKILL_LAUNCH,
|
||||
} from './constants.js';
|
||||
import {
|
||||
recordApiErrorMetrics,
|
||||
@@ -86,7 +85,6 @@ import type {
|
||||
MalformedJsonResponseEvent,
|
||||
InvalidChunkEvent,
|
||||
AuthEvent,
|
||||
SkillLaunchEvent,
|
||||
} from './types.js';
|
||||
import type { UiEvent } from './uiTelemetry.js';
|
||||
import { uiTelemetryService } from './uiTelemetry.js';
|
||||
@@ -129,8 +127,6 @@ export function logStartSession(
|
||||
mcp_tools: event.mcp_tools,
|
||||
mcp_tools_count: event.mcp_tools_count,
|
||||
output_format: event.output_format,
|
||||
skills: event.skills,
|
||||
subagents: event.subagents,
|
||||
};
|
||||
|
||||
const logger = logs.getLogger(SERVICE_NAME);
|
||||
@@ -873,21 +869,3 @@ export function logAuth(config: Config, event: AuthEvent): void {
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
export function logSkillLaunch(config: Config, event: SkillLaunchEvent): void {
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
...getCommonAttributes(config),
|
||||
...event,
|
||||
'event.name': EVENT_SKILL_LAUNCH,
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
};
|
||||
|
||||
const logger = logs.getLogger(SERVICE_NAME);
|
||||
const logRecord: LogRecord = {
|
||||
body: `Skill launch: ${event.skill_name}. Success: ${event.success}.`,
|
||||
attributes,
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ import type {
|
||||
ModelSlashCommandEvent,
|
||||
ExtensionDisableEvent,
|
||||
AuthEvent,
|
||||
SkillLaunchEvent,
|
||||
RipgrepFallbackEvent,
|
||||
EndSessionEvent,
|
||||
} from '../types.js';
|
||||
@@ -392,8 +391,6 @@ export class QwenLogger {
|
||||
telemetry_enabled: event.telemetry_enabled,
|
||||
telemetry_log_user_prompts_enabled:
|
||||
event.telemetry_log_user_prompts_enabled,
|
||||
skills: event.skills,
|
||||
subagents: event.subagents,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -830,18 +827,6 @@ export class QwenLogger {
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logSkillLaunchEvent(event: SkillLaunchEvent): void {
|
||||
const rumEvent = this.createActionEvent('misc', 'skill_launch', {
|
||||
properties: {
|
||||
skill_name: event.skill_name,
|
||||
success: event.success ? 1 : 0,
|
||||
},
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logChatCompressionEvent(event: ChatCompressionEvent): void {
|
||||
const rumEvent = this.createActionEvent('misc', 'chat_compression', {
|
||||
properties: {
|
||||
|
||||
@@ -18,9 +18,6 @@ import {
|
||||
import type { FileOperation } from './metrics.js';
|
||||
export { ToolCallDecision };
|
||||
import type { OutputFormat } from '../output/types.js';
|
||||
import { ToolNames } from '../tools/tool-names.js';
|
||||
import type { SkillTool } from '../tools/skill.js';
|
||||
import type { TaskTool } from '../tools/task.js';
|
||||
|
||||
export interface BaseTelemetryEvent {
|
||||
'event.name': string;
|
||||
@@ -50,8 +47,6 @@ export class StartSessionEvent implements BaseTelemetryEvent {
|
||||
mcp_tools_count?: number;
|
||||
mcp_tools?: string;
|
||||
output_format: OutputFormat;
|
||||
skills?: string;
|
||||
subagents?: string;
|
||||
|
||||
constructor(config: Config) {
|
||||
const generatorConfig = config.getContentGeneratorConfig();
|
||||
@@ -84,7 +79,6 @@ export class StartSessionEvent implements BaseTelemetryEvent {
|
||||
config.getFileFilteringRespectGitIgnore();
|
||||
this.mcp_servers_count = mcpServers ? Object.keys(mcpServers).length : 0;
|
||||
this.output_format = config.getOutputFormat();
|
||||
|
||||
if (toolRegistry) {
|
||||
const mcpTools = toolRegistry
|
||||
.getAllTools()
|
||||
@@ -93,22 +87,6 @@ export class StartSessionEvent implements BaseTelemetryEvent {
|
||||
this.mcp_tools = mcpTools
|
||||
.map((tool) => (tool as DiscoveredMCPTool).name)
|
||||
.join(',');
|
||||
|
||||
const skillTool = toolRegistry.getTool(ToolNames.SKILL) as
|
||||
| SkillTool
|
||||
| undefined;
|
||||
const skillNames = skillTool?.getAvailableSkillNames?.();
|
||||
if (skillNames && skillNames.length > 0) {
|
||||
this.skills = skillNames.join(',');
|
||||
}
|
||||
|
||||
const taskTool = toolRegistry.getTool(ToolNames.TASK) as
|
||||
| TaskTool
|
||||
| undefined;
|
||||
const subagentNames = taskTool?.getAvailableSubagentNames?.();
|
||||
if (subagentNames && subagentNames.length > 0) {
|
||||
this.subagents = subagentNames.join(',');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -743,20 +721,6 @@ export class AuthEvent implements BaseTelemetryEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export class SkillLaunchEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'skill_launch';
|
||||
'event.timestamp': string;
|
||||
skill_name: string;
|
||||
success: boolean;
|
||||
|
||||
constructor(skill_name: string, success: boolean) {
|
||||
this['event.name'] = 'skill_launch';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.skill_name = skill_name;
|
||||
this.success = success;
|
||||
}
|
||||
}
|
||||
|
||||
export type TelemetryEvent =
|
||||
| StartSessionEvent
|
||||
| EndSessionEvent
|
||||
@@ -785,8 +749,7 @@ export type TelemetryEvent =
|
||||
| ExtensionUninstallEvent
|
||||
| ToolOutputTruncatedEvent
|
||||
| ModelSlashCommandEvent
|
||||
| AuthEvent
|
||||
| SkillLaunchEvent;
|
||||
| AuthEvent;
|
||||
|
||||
export class ExtensionDisableEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'extension_disable';
|
||||
|
||||
@@ -31,8 +31,6 @@ describe('LSTool', () => {
|
||||
tempSecondaryDir,
|
||||
]);
|
||||
|
||||
const userSkillsBase = path.join(os.homedir(), '.qwen', 'skills');
|
||||
|
||||
mockConfig = {
|
||||
getTargetDir: () => tempRootDir,
|
||||
getWorkspaceContext: () => mockWorkspaceContext,
|
||||
@@ -41,9 +39,6 @@ describe('LSTool', () => {
|
||||
respectGitIgnore: true,
|
||||
respectQwenIgnore: true,
|
||||
}),
|
||||
storage: {
|
||||
getUserSkillsDir: () => userSkillsBase,
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
lsTool = new LSTool(mockConfig);
|
||||
@@ -293,7 +288,7 @@ describe('LSTool', () => {
|
||||
};
|
||||
const invocation = lsTool.build(params);
|
||||
const description = invocation.getDescription();
|
||||
const expected = path.resolve(params.path);
|
||||
const expected = path.relative(tempRootDir, params.path);
|
||||
expect(description).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import path from 'node:path';
|
||||
import type { ToolInvocation, ToolResult } from './tools.js';
|
||||
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { isSubpath } from '../utils/paths.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
@@ -312,14 +311,8 @@ export class LSTool extends BaseDeclarativeTool<LSToolParams, ToolResult> {
|
||||
return `Path must be absolute: ${params.path}`;
|
||||
}
|
||||
|
||||
const userSkillsBase = this.config.storage.getUserSkillsDir();
|
||||
const isUnderUserSkills = isSubpath(userSkillsBase, params.path);
|
||||
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
if (
|
||||
!workspaceContext.isPathWithinWorkspace(params.path) &&
|
||||
!isUnderUserSkills
|
||||
) {
|
||||
if (!workspaceContext.isPathWithinWorkspace(params.path)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
return `Path must be within one of the workspace directories: ${directories.join(
|
||||
', ',
|
||||
|
||||
@@ -40,7 +40,6 @@ describe('ReadFileTool', () => {
|
||||
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
|
||||
storage: {
|
||||
getProjectTempDir: () => path.join(tempRootDir, '.temp'),
|
||||
getUserSkillsDir: () => path.join(os.homedir(), '.qwen', 'skills'),
|
||||
},
|
||||
getTruncateToolOutputThreshold: () => 2500,
|
||||
getTruncateToolOutputLines: () => 500,
|
||||
|
||||
@@ -20,7 +20,6 @@ import { FileOperation } from '../telemetry/metrics.js';
|
||||
import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js';
|
||||
import { logFileOperation } from '../telemetry/loggers.js';
|
||||
import { FileOperationEvent } from '../telemetry/types.js';
|
||||
import { isSubpath } from '../utils/paths.js';
|
||||
|
||||
/**
|
||||
* Parameters for the ReadFile tool
|
||||
@@ -184,20 +183,15 @@ export class ReadFileTool extends BaseDeclarativeTool<
|
||||
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
const projectTempDir = this.config.storage.getProjectTempDir();
|
||||
const userSkillsDir = this.config.storage.getUserSkillsDir();
|
||||
const resolvedFilePath = path.resolve(filePath);
|
||||
const isWithinTempDir = isSubpath(projectTempDir, resolvedFilePath);
|
||||
const isWithinUserSkills = isSubpath(userSkillsDir, resolvedFilePath);
|
||||
const resolvedProjectTempDir = path.resolve(projectTempDir);
|
||||
const isWithinTempDir =
|
||||
resolvedFilePath.startsWith(resolvedProjectTempDir + path.sep) ||
|
||||
resolvedFilePath === resolvedProjectTempDir;
|
||||
|
||||
if (
|
||||
!workspaceContext.isPathWithinWorkspace(filePath) &&
|
||||
!isWithinTempDir &&
|
||||
!isWithinUserSkills
|
||||
) {
|
||||
if (!workspaceContext.isPathWithinWorkspace(filePath) && !isWithinTempDir) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
return `File path must be within one of the workspace directories: ${directories.join(
|
||||
', ',
|
||||
)} or within the project temp directory: ${projectTempDir}`;
|
||||
return `File path must be within one of the workspace directories: ${directories.join(', ')} or within the project temp directory: ${projectTempDir}`;
|
||||
}
|
||||
if (params.offset !== undefined && params.offset < 0) {
|
||||
return 'Offset must be a non-negative number';
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { SkillTool, type SkillParams } from './skill.js';
|
||||
import type { PartListUnion } from '@google/genai';
|
||||
import type { ToolResultDisplay } from './tools.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { SkillManager } from '../skills/skill-manager.js';
|
||||
import type { SkillConfig } from '../skills/types.js';
|
||||
import { partToString } from '../utils/partUtils.js';
|
||||
|
||||
// Type for accessing protected methods in tests
|
||||
type SkillToolWithProtectedMethods = SkillTool & {
|
||||
createInvocation: (params: SkillParams) => {
|
||||
execute: (
|
||||
signal?: AbortSignal,
|
||||
updateOutput?: (output: ToolResultDisplay) => void,
|
||||
) => Promise<{
|
||||
llmContent: PartListUnion;
|
||||
returnDisplay: ToolResultDisplay;
|
||||
}>;
|
||||
getDescription: () => string;
|
||||
shouldConfirmExecute: () => Promise<boolean>;
|
||||
};
|
||||
};
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../skills/skill-manager.js');
|
||||
vi.mock('../telemetry/index.js', () => ({
|
||||
logSkillLaunch: vi.fn(),
|
||||
SkillLaunchEvent: class {
|
||||
constructor(
|
||||
public skill_name: string,
|
||||
public success: boolean,
|
||||
) {}
|
||||
},
|
||||
}));
|
||||
|
||||
const MockedSkillManager = vi.mocked(SkillManager);
|
||||
|
||||
describe('SkillTool', () => {
|
||||
let config: Config;
|
||||
let skillTool: SkillTool;
|
||||
let mockSkillManager: SkillManager;
|
||||
let changeListeners: Array<() => void>;
|
||||
|
||||
const mockSkills: SkillConfig[] = [
|
||||
{
|
||||
name: 'code-review',
|
||||
description: 'Specialized skill for reviewing code quality',
|
||||
level: 'project',
|
||||
filePath: '/project/.qwen/skills/code-review/SKILL.md',
|
||||
body: 'Review code for quality and best practices.',
|
||||
},
|
||||
{
|
||||
name: 'testing',
|
||||
description: 'Skill for writing and running tests',
|
||||
level: 'user',
|
||||
filePath: '/home/user/.qwen/skills/testing/SKILL.md',
|
||||
body: 'Help write comprehensive tests.',
|
||||
allowedTools: ['read_file', 'write_file', 'shell'],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
// Setup fake timers
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Create mock config
|
||||
config = {
|
||||
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getSkillManager: vi.fn(),
|
||||
getGeminiClient: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
changeListeners = [];
|
||||
|
||||
// Setup SkillManager mock
|
||||
mockSkillManager = {
|
||||
listSkills: vi.fn().mockResolvedValue(mockSkills),
|
||||
loadSkill: vi.fn(),
|
||||
loadSkillForRuntime: vi.fn(),
|
||||
addChangeListener: vi.fn((listener: () => void) => {
|
||||
changeListeners.push(listener);
|
||||
return () => {
|
||||
const index = changeListeners.indexOf(listener);
|
||||
if (index >= 0) {
|
||||
changeListeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}),
|
||||
getParseErrors: vi.fn().mockReturnValue(new Map()),
|
||||
} as unknown as SkillManager;
|
||||
|
||||
MockedSkillManager.mockImplementation(() => mockSkillManager);
|
||||
|
||||
// Make config return the mock SkillManager
|
||||
vi.mocked(config.getSkillManager).mockReturnValue(mockSkillManager);
|
||||
|
||||
// Create SkillTool instance
|
||||
skillTool = new SkillTool(config);
|
||||
|
||||
// Allow async initialization to complete
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with correct name and properties', () => {
|
||||
expect(skillTool.name).toBe('skill');
|
||||
expect(skillTool.displayName).toBe('Skill');
|
||||
expect(skillTool.kind).toBe('read');
|
||||
});
|
||||
|
||||
it('should load available skills during initialization', () => {
|
||||
expect(mockSkillManager.listSkills).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should subscribe to skill manager changes', () => {
|
||||
expect(mockSkillManager.addChangeListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should update description with available skills', () => {
|
||||
expect(skillTool.description).toContain('code-review');
|
||||
expect(skillTool.description).toContain(
|
||||
'Specialized skill for reviewing code quality',
|
||||
);
|
||||
expect(skillTool.description).toContain('testing');
|
||||
expect(skillTool.description).toContain(
|
||||
'Skill for writing and running tests',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty skills list gracefully', async () => {
|
||||
vi.mocked(mockSkillManager.listSkills).mockResolvedValue([]);
|
||||
|
||||
const emptySkillTool = new SkillTool(config);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(emptySkillTool.description).toContain(
|
||||
'No skills are currently configured',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle skill loading errors gracefully', async () => {
|
||||
vi.mocked(mockSkillManager.listSkills).mockRejectedValue(
|
||||
new Error('Loading failed'),
|
||||
);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
new SkillTool(config);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to load skills for Skills tool:',
|
||||
expect.any(Error),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema generation', () => {
|
||||
it('should expose static schema without dynamic enums', () => {
|
||||
const schema = skillTool.schema;
|
||||
const properties = schema.parametersJsonSchema as {
|
||||
properties: {
|
||||
skill: {
|
||||
type: string;
|
||||
description: string;
|
||||
enum?: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(properties.properties.skill.type).toBe('string');
|
||||
expect(properties.properties.skill.description).toBe(
|
||||
'The skill name (no arguments). E.g., "pdf" or "xlsx"',
|
||||
);
|
||||
expect(properties.properties.skill.enum).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should keep schema static even when no skills available', async () => {
|
||||
vi.mocked(mockSkillManager.listSkills).mockResolvedValue([]);
|
||||
|
||||
const emptySkillTool = new SkillTool(config);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const schema = emptySkillTool.schema;
|
||||
const properties = schema.parametersJsonSchema as {
|
||||
properties: {
|
||||
skill: {
|
||||
type: string;
|
||||
description: string;
|
||||
enum?: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(properties.properties.skill.type).toBe('string');
|
||||
expect(properties.properties.skill.description).toBe(
|
||||
'The skill name (no arguments). E.g., "pdf" or "xlsx"',
|
||||
);
|
||||
expect(properties.properties.skill.enum).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToolParams', () => {
|
||||
it('should validate valid parameters', () => {
|
||||
const result = skillTool.validateToolParams({ skill: 'code-review' });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject empty skill', () => {
|
||||
const result = skillTool.validateToolParams({ skill: '' });
|
||||
expect(result).toBe('Parameter "skill" must be a non-empty string.');
|
||||
});
|
||||
|
||||
it('should reject non-existent skill', () => {
|
||||
const result = skillTool.validateToolParams({
|
||||
skill: 'non-existent',
|
||||
});
|
||||
expect(result).toBe(
|
||||
'Skill "non-existent" not found. Available skills: code-review, testing',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show appropriate message when no skills available', async () => {
|
||||
vi.mocked(mockSkillManager.listSkills).mockResolvedValue([]);
|
||||
|
||||
const emptySkillTool = new SkillTool(config);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const result = emptySkillTool.validateToolParams({
|
||||
skill: 'non-existent',
|
||||
});
|
||||
expect(result).toBe(
|
||||
'Skill "non-existent" not found. No skills are currently available.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshSkills', () => {
|
||||
it('should refresh when change listener fires', async () => {
|
||||
const newSkills: SkillConfig[] = [
|
||||
{
|
||||
name: 'new-skill',
|
||||
description: 'A brand new skill',
|
||||
level: 'project',
|
||||
filePath: '/project/.qwen/skills/new-skill/SKILL.md',
|
||||
body: 'New skill content.',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockSkillManager.listSkills).mockResolvedValueOnce(newSkills);
|
||||
|
||||
const listener = changeListeners[0];
|
||||
expect(listener).toBeDefined();
|
||||
|
||||
listener?.();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(skillTool.description).toContain('new-skill');
|
||||
expect(skillTool.description).toContain('A brand new skill');
|
||||
});
|
||||
|
||||
it('should refresh available skills and update description', async () => {
|
||||
const newSkills: SkillConfig[] = [
|
||||
{
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
level: 'project',
|
||||
filePath: '/project/.qwen/skills/test-skill/SKILL.md',
|
||||
body: 'Test content.',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockSkillManager.listSkills).mockResolvedValue(newSkills);
|
||||
|
||||
await skillTool.refreshSkills();
|
||||
|
||||
expect(skillTool.description).toContain('test-skill');
|
||||
expect(skillTool.description).toContain('A test skill');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SkillToolInvocation', () => {
|
||||
const mockRuntimeConfig: SkillConfig = {
|
||||
...mockSkills[0],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(
|
||||
mockRuntimeConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it('should execute skill load successfully', async () => {
|
||||
const params: SkillParams = {
|
||||
skill: 'code-review',
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
skillTool as SkillToolWithProtectedMethods
|
||||
).createInvocation(params);
|
||||
const result = await invocation.execute();
|
||||
|
||||
expect(mockSkillManager.loadSkillForRuntime).toHaveBeenCalledWith(
|
||||
'code-review',
|
||||
);
|
||||
|
||||
const llmText = partToString(result.llmContent);
|
||||
expect(llmText).toContain(
|
||||
'Base directory for this skill: /project/.qwen/skills/code-review',
|
||||
);
|
||||
expect(llmText.trim()).toContain(
|
||||
'Review code for quality and best practices.',
|
||||
);
|
||||
|
||||
expect(result.returnDisplay).toBe('Launching skill: code-review');
|
||||
});
|
||||
|
||||
it('should include allowedTools in result when present', async () => {
|
||||
const skillWithTools: SkillConfig = {
|
||||
...mockSkills[1],
|
||||
};
|
||||
vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(
|
||||
skillWithTools,
|
||||
);
|
||||
|
||||
const params: SkillParams = {
|
||||
skill: 'testing',
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
skillTool as SkillToolWithProtectedMethods
|
||||
).createInvocation(params);
|
||||
const result = await invocation.execute();
|
||||
|
||||
const llmText = partToString(result.llmContent);
|
||||
expect(llmText).toContain('testing');
|
||||
// Base description is omitted from llmContent; ensure body is present.
|
||||
expect(llmText).toContain('Help write comprehensive tests.');
|
||||
|
||||
expect(result.returnDisplay).toBe('Launching skill: testing');
|
||||
});
|
||||
|
||||
it('should handle skill not found error', async () => {
|
||||
vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(null);
|
||||
|
||||
const params: SkillParams = {
|
||||
skill: 'non-existent',
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
skillTool as SkillToolWithProtectedMethods
|
||||
).createInvocation(params);
|
||||
const result = await invocation.execute();
|
||||
|
||||
const llmText = partToString(result.llmContent);
|
||||
expect(llmText).toContain('Skill "non-existent" not found');
|
||||
});
|
||||
|
||||
it('should handle execution errors gracefully', async () => {
|
||||
vi.mocked(mockSkillManager.loadSkillForRuntime).mockRejectedValue(
|
||||
new Error('Loading failed'),
|
||||
);
|
||||
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const params: SkillParams = {
|
||||
skill: 'code-review',
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
skillTool as SkillToolWithProtectedMethods
|
||||
).createInvocation(params);
|
||||
const result = await invocation.execute();
|
||||
|
||||
const llmText = partToString(result.llmContent);
|
||||
expect(llmText).toContain('Failed to load skill');
|
||||
expect(llmText).toContain('Loading failed');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not require confirmation', async () => {
|
||||
const params: SkillParams = {
|
||||
skill: 'code-review',
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
skillTool as SkillToolWithProtectedMethods
|
||||
).createInvocation(params);
|
||||
const shouldConfirm = await invocation.shouldConfirmExecute();
|
||||
|
||||
expect(shouldConfirm).toBe(false);
|
||||
});
|
||||
|
||||
it('should provide correct description', () => {
|
||||
const params: SkillParams = {
|
||||
skill: 'code-review',
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
skillTool as SkillToolWithProtectedMethods
|
||||
).createInvocation(params);
|
||||
const description = invocation.getDescription();
|
||||
|
||||
expect(description).toBe('Launching skill: "code-review"');
|
||||
});
|
||||
|
||||
it('should handle skill without additional files', async () => {
|
||||
vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(
|
||||
mockSkills[0],
|
||||
);
|
||||
|
||||
const params: SkillParams = {
|
||||
skill: 'code-review',
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
skillTool as SkillToolWithProtectedMethods
|
||||
).createInvocation(params);
|
||||
const result = await invocation.execute();
|
||||
|
||||
const llmText = partToString(result.llmContent);
|
||||
expect(llmText).not.toContain('## Additional Files');
|
||||
|
||||
expect(result.returnDisplay).toBe('Launching skill: code-review');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||
import { ToolNames, ToolDisplayNames } from './tool-names.js';
|
||||
import type { ToolResult, ToolResultDisplay } from './tools.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { SkillManager } from '../skills/skill-manager.js';
|
||||
import type { SkillConfig } from '../skills/types.js';
|
||||
import { logSkillLaunch, SkillLaunchEvent } from '../telemetry/index.js';
|
||||
import path from 'path';
|
||||
|
||||
export interface SkillParams {
|
||||
skill: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill tool that enables the model to access skill definitions.
|
||||
* The tool dynamically loads available skills and includes them in its description
|
||||
* for the model to choose from.
|
||||
*/
|
||||
export class SkillTool extends BaseDeclarativeTool<SkillParams, ToolResult> {
|
||||
static readonly Name: string = ToolNames.SKILL;
|
||||
|
||||
private skillManager: SkillManager;
|
||||
private availableSkills: SkillConfig[] = [];
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
// Initialize with a basic schema first
|
||||
const initialSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
skill: {
|
||||
type: 'string',
|
||||
description: 'The skill name (no arguments). E.g., "pdf" or "xlsx"',
|
||||
},
|
||||
},
|
||||
required: ['skill'],
|
||||
additionalProperties: false,
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
};
|
||||
|
||||
super(
|
||||
SkillTool.Name,
|
||||
ToolDisplayNames.SKILL,
|
||||
'Execute a skill within the main conversation. Loading available skills...', // Initial description
|
||||
Kind.Read,
|
||||
initialSchema,
|
||||
true, // isOutputMarkdown
|
||||
false, // canUpdateOutput
|
||||
);
|
||||
|
||||
this.skillManager = config.getSkillManager();
|
||||
this.skillManager.addChangeListener(() => {
|
||||
void this.refreshSkills();
|
||||
});
|
||||
|
||||
// Initialize the tool asynchronously
|
||||
this.refreshSkills();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously initializes the tool by loading available skills
|
||||
* and updating the description and schema.
|
||||
*/
|
||||
async refreshSkills(): Promise<void> {
|
||||
try {
|
||||
this.availableSkills = await this.skillManager.listSkills();
|
||||
this.updateDescriptionAndSchema();
|
||||
} catch (error) {
|
||||
console.warn('Failed to load skills for Skills tool:', error);
|
||||
this.availableSkills = [];
|
||||
this.updateDescriptionAndSchema();
|
||||
} finally {
|
||||
// Update the client with the new tools
|
||||
const geminiClient = this.config.getGeminiClient();
|
||||
if (geminiClient && geminiClient.isInitialized()) {
|
||||
await geminiClient.setTools();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the tool's description and schema based on available skills.
|
||||
*/
|
||||
private updateDescriptionAndSchema(): void {
|
||||
let skillDescriptions = '';
|
||||
if (this.availableSkills.length === 0) {
|
||||
skillDescriptions =
|
||||
'No skills are currently configured. Skills can be created by adding directories with SKILL.md files to .qwen/skills/ or ~/.qwen/skills/.';
|
||||
} else {
|
||||
skillDescriptions = this.availableSkills
|
||||
.map(
|
||||
(skill) => `<skill>
|
||||
<name>
|
||||
${skill.name}
|
||||
</name>
|
||||
<description>
|
||||
${skill.description} (${skill.level})
|
||||
</description>
|
||||
<location>
|
||||
${skill.level}
|
||||
</location>
|
||||
</skill>`,
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const baseDescription = `Execute a skill within the main conversation
|
||||
|
||||
<skills_instructions>
|
||||
When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
|
||||
|
||||
How to invoke:
|
||||
- Use this tool with the skill name only (no arguments)
|
||||
- Examples:
|
||||
- \`skill: "pdf"\` - invoke the pdf skill
|
||||
- \`skill: "xlsx"\` - invoke the xlsx skill
|
||||
- \`skill: "ms-office-suite:pdf"\` - invoke using fully qualified name
|
||||
|
||||
Important:
|
||||
- When a skill is relevant, you must invoke this tool IMMEDIATELY as your first action
|
||||
- NEVER just announce or mention a skill in your text response without actually calling this tool
|
||||
- This is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task
|
||||
- Only use skills listed in <available_skills> below
|
||||
- Do not invoke a skill that is already running
|
||||
- Do not use this tool for built-in CLI commands (like /help, /clear, etc.)
|
||||
</skills_instructions>
|
||||
|
||||
<available_skills>
|
||||
${skillDescriptions}
|
||||
</available_skills>
|
||||
`;
|
||||
// Update description using object property assignment
|
||||
(this as { description: string }).description = baseDescription;
|
||||
}
|
||||
|
||||
override validateToolParams(params: SkillParams): string | null {
|
||||
// Validate required fields
|
||||
if (
|
||||
!params.skill ||
|
||||
typeof params.skill !== 'string' ||
|
||||
params.skill.trim() === ''
|
||||
) {
|
||||
return 'Parameter "skill" must be a non-empty string.';
|
||||
}
|
||||
|
||||
// Validate that the skill exists
|
||||
const skillExists = this.availableSkills.some(
|
||||
(skill) => skill.name === params.skill,
|
||||
);
|
||||
|
||||
if (!skillExists) {
|
||||
const availableNames = this.availableSkills.map((s) => s.name);
|
||||
if (availableNames.length === 0) {
|
||||
return `Skill "${params.skill}" not found. No skills are currently available.`;
|
||||
}
|
||||
return `Skill "${params.skill}" not found. Available skills: ${availableNames.join(', ')}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected createInvocation(params: SkillParams) {
|
||||
return new SkillToolInvocation(this.config, this.skillManager, params);
|
||||
}
|
||||
|
||||
getAvailableSkillNames(): string[] {
|
||||
return this.availableSkills.map((skill) => skill.name);
|
||||
}
|
||||
}
|
||||
|
||||
class SkillToolInvocation extends BaseToolInvocation<SkillParams, ToolResult> {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly skillManager: SkillManager,
|
||||
params: SkillParams,
|
||||
) {
|
||||
super(params);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Launching skill: "${this.params.skill}"`;
|
||||
}
|
||||
|
||||
override async shouldConfirmExecute(): Promise<false> {
|
||||
// Skill loading is a read-only operation, no confirmation needed
|
||||
return false;
|
||||
}
|
||||
|
||||
async execute(
|
||||
_signal?: AbortSignal,
|
||||
_updateOutput?: (output: ToolResultDisplay) => void,
|
||||
): Promise<ToolResult> {
|
||||
try {
|
||||
// Load the skill with runtime config (includes additional files)
|
||||
const skill = await this.skillManager.loadSkillForRuntime(
|
||||
this.params.skill,
|
||||
);
|
||||
|
||||
if (!skill) {
|
||||
// Log failed skill launch
|
||||
logSkillLaunch(
|
||||
this.config,
|
||||
new SkillLaunchEvent(this.params.skill, false),
|
||||
);
|
||||
|
||||
// Get parse errors if any
|
||||
const parseErrors = this.skillManager.getParseErrors();
|
||||
const errorMessages: string[] = [];
|
||||
|
||||
for (const [filePath, error] of parseErrors) {
|
||||
if (filePath.includes(this.params.skill)) {
|
||||
errorMessages.push(`Parse error at ${filePath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const errorDetail =
|
||||
errorMessages.length > 0
|
||||
? `\nErrors:\n${errorMessages.join('\n')}`
|
||||
: '';
|
||||
|
||||
return {
|
||||
llmContent: `Skill "${this.params.skill}" not found.${errorDetail}`,
|
||||
returnDisplay: `Skill "${this.params.skill}" not found.${errorDetail}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Log successful skill launch
|
||||
logSkillLaunch(
|
||||
this.config,
|
||||
new SkillLaunchEvent(this.params.skill, true),
|
||||
);
|
||||
|
||||
const baseDir = path.dirname(skill.filePath);
|
||||
|
||||
// Build markdown content for LLM (show base dir, then body)
|
||||
const llmContent = `Base directory for this skill: ${baseDir}\n\n${skill.body}\n`;
|
||||
|
||||
return {
|
||||
llmContent: [{ text: llmContent }],
|
||||
returnDisplay: `Launching skill: ${skill.name}`,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error(`[SkillsTool] Error launching skill: ${errorMessage}`);
|
||||
|
||||
// Log failed skill launch
|
||||
logSkillLaunch(
|
||||
this.config,
|
||||
new SkillLaunchEvent(this.params.skill, false),
|
||||
);
|
||||
|
||||
return {
|
||||
llmContent: `Failed to load skill "${this.params.skill}": ${errorMessage}`,
|
||||
returnDisplay: `Failed to load skill "${this.params.skill}": ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,10 +252,6 @@ assistant: "I'm going to use the Task tool to launch the with the greeting-respo
|
||||
protected createInvocation(params: TaskParams) {
|
||||
return new TaskToolInvocation(this.config, this.subagentManager, params);
|
||||
}
|
||||
|
||||
getAvailableSubagentNames(): string[] {
|
||||
return this.availableSubagents.map((subagent) => subagent.name);
|
||||
}
|
||||
}
|
||||
|
||||
class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
|
||||
|
||||
@@ -20,7 +20,6 @@ export const ToolNames = {
|
||||
TODO_WRITE: 'todo_write',
|
||||
MEMORY: 'save_memory',
|
||||
TASK: 'task',
|
||||
SKILL: 'skill',
|
||||
EXIT_PLAN_MODE: 'exit_plan_mode',
|
||||
WEB_FETCH: 'web_fetch',
|
||||
WEB_SEARCH: 'web_search',
|
||||
@@ -43,7 +42,6 @@ export const ToolDisplayNames = {
|
||||
TODO_WRITE: 'TodoWrite',
|
||||
MEMORY: 'SaveMemory',
|
||||
TASK: 'Task',
|
||||
SKILL: 'Skill',
|
||||
EXIT_PLAN_MODE: 'ExitPlanMode',
|
||||
WEB_FETCH: 'WebFetch',
|
||||
WEB_SEARCH: 'WebSearch',
|
||||
|
||||
@@ -120,10 +120,6 @@ export function makeRelative(
|
||||
const resolvedTargetPath = path.resolve(targetPath);
|
||||
const resolvedRootDirectory = path.resolve(rootDirectory);
|
||||
|
||||
if (!isSubpath(resolvedRootDirectory, resolvedTargetPath)) {
|
||||
return resolvedTargetPath;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(resolvedRootDirectory, resolvedTargetPath);
|
||||
|
||||
// If the paths are the same, path.relative returns '', return '.' instead
|
||||
|
||||
@@ -33,7 +33,7 @@ interface CheckResult {
|
||||
*/
|
||||
async function loadTranslationsFile(
|
||||
filePath: string,
|
||||
): Promise<Record<string, string>> {
|
||||
): Promise<Record<string, string | string[]>> {
|
||||
try {
|
||||
// Dynamic import for ES modules
|
||||
const module = await import(filePath);
|
||||
@@ -118,8 +118,8 @@ async function extractUsedKeys(sourceDir: string): Promise<Set<string>> {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
// Find all t( calls
|
||||
const tCallRegex = /t\s*\(/g;
|
||||
// Find all t( or ta( calls
|
||||
const tCallRegex = /\bta?\s*\(/g;
|
||||
let match;
|
||||
while ((match = tCallRegex.exec(content)) !== null) {
|
||||
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
|
||||
*/
|
||||
function checkKeyValueConsistency(
|
||||
enTranslations: Record<string, string>,
|
||||
enTranslations: Record<string, string | string[]>,
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
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) {
|
||||
errors.push(`Key-value mismatch: "${key}" !== "${value}"`);
|
||||
}
|
||||
@@ -170,8 +175,8 @@ function checkKeyValueConsistency(
|
||||
* Check if en.js and zh.js have matching keys
|
||||
*/
|
||||
function checkKeyMatching(
|
||||
enTranslations: Record<string, string>,
|
||||
zhTranslations: Record<string, string>,
|
||||
enTranslations: Record<string, string | string[]>,
|
||||
zhTranslations: Record<string, string | string[]>,
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
const enKeys = new Set(Object.keys(enTranslations));
|
||||
@@ -301,8 +306,8 @@ async function checkI18n(): Promise<CheckResult> {
|
||||
const zhPath = path.join(localesDir, 'zh.js');
|
||||
|
||||
// Load translation files
|
||||
let enTranslations: Record<string, string>;
|
||||
let zhTranslations: Record<string, string>;
|
||||
let enTranslations: Record<string, string | string[]>;
|
||||
let zhTranslations: Record<string, string | string[]>;
|
||||
|
||||
try {
|
||||
enTranslations = await loadTranslationsFile(enPath);
|
||||
|
||||
61
scripts/unused-keys-only-in-locales.json
Normal file
61
scripts/unused-keys-only-in-locales.json
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user