Compare commits

..

1 Commits

Author SHA1 Message Date
mingholy.lmh
ea287fae98 docs: add reasoning configuration and sampling parameters instructions 2025-12-29 15:37:08 +08:00
218 changed files with 2538 additions and 20731 deletions

1
.gitignore vendored
View File

@@ -23,7 +23,6 @@ package-lock.json
.idea .idea
*.iml *.iml
.cursor .cursor
.qoder
# OS metadata # OS metadata
.DS_Store .DS_Store

View File

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

View File

@@ -11,7 +11,6 @@ export default {
type: 'separator', type: 'separator',
}, },
'sdk-typescript': 'Typescript SDK', 'sdk-typescript': 'Typescript SDK',
'sdk-java': 'Java SDK(alpha)',
'Dive Into Qwen Code': { 'Dive Into Qwen Code': {
title: 'Dive Into Qwen Code', title: 'Dive Into Qwen Code',
type: 'separator', type: 'separator',

View File

@@ -1,312 +0,0 @@
# Qwen Code Java SDK
The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications.
## Requirements
- Java >= 1.8
- Maven >= 3.6.0 (for building from source)
- qwen-code >= 0.5.0
### Dependencies
- **Logging**: ch.qos.logback:logback-classic
- **Utilities**: org.apache.commons:commons-lang3
- **JSON Processing**: com.alibaba.fastjson2:fastjson2
- **Testing**: JUnit 5 (org.junit.jupiter:junit-jupiter)
## Installation
Add the following dependency to your Maven `pom.xml`:
```xml
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>qwencode-sdk</artifactId>
<version>{$version}</version>
</dependency>
```
Or if using Gradle, add to your `build.gradle`:
```gradle
implementation 'com.alibaba:qwencode-sdk:{$version}'
```
## Building and Running
### Build Commands
```bash
# Compile the project
mvn compile
# Run tests
mvn test
# Package the JAR
mvn package
# Install to local repository
mvn install
```
## Quick Start
The simplest way to use the SDK is through the `QwenCodeCli.simpleQuery()` method:
```java
public static void runSimpleExample() {
List<String> result = QwenCodeCli.simpleQuery("hello world");
result.forEach(logger::info);
}
```
For more advanced usage with custom transport options:
```java
public static void runTransportOptionsExample() {
TransportOptions options = new TransportOptions()
.setModel("qwen3-coder-flash")
.setPermissionMode(PermissionMode.AUTO_EDIT)
.setCwd("./")
.setEnv(new HashMap<String, String>() {{put("CUSTOM_VAR", "value");}})
.setIncludePartialMessages(true)
.setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS))
.setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS))
.setAllowedTools(Arrays.asList("read_file", "write_file", "list_directory"));
List<String> result = QwenCodeCli.simpleQuery("who are you, what are your capabilities?", options);
result.forEach(logger::info);
}
```
For streaming content handling with custom content consumers:
```java
public static void runStreamingExample() {
QwenCodeCli.simpleQuery("who are you, what are your capabilities?",
new TransportOptions().setMessageTimeout(new Timeout(10L, TimeUnit.SECONDS)), new AssistantContentSimpleConsumers() {
@Override
public void onText(Session session, TextAssistantContent textAssistantContent) {
logger.info("Text content received: {}", textAssistantContent.getText());
}
@Override
public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) {
logger.info("Thinking content received: {}", thingkingAssistantContent.getThinking());
}
@Override
public void onToolUse(Session session, ToolUseAssistantContent toolUseContent) {
logger.info("Tool use content received: {} with arguments: {}",
toolUseContent, toolUseContent.getInput());
}
@Override
public void onToolResult(Session session, ToolResultAssistantContent toolResultContent) {
logger.info("Tool result content received: {}", toolResultContent.getContent());
}
@Override
public void onOtherContent(Session session, AssistantContent<?> other) {
logger.info("Other content received: {}", other);
}
@Override
public void onUsage(Session session, AssistantUsage assistantUsage) {
logger.info("Usage information received: Input tokens: {}, Output tokens: {}",
assistantUsage.getUsage().getInputTokens(), assistantUsage.getUsage().getOutputTokens());
}
}.setDefaultPermissionOperation(Operation.allow));
logger.info("Streaming example completed.");
}
```
other examples see src/test/java/com/alibaba/qwen/code/cli/example
## Architecture
The SDK follows a layered architecture:
- **API Layer**: Provides the main entry points through `QwenCodeCli` class with simple static methods for basic usage
- **Session Layer**: Manages communication sessions with the Qwen Code CLI through the `Session` class
- **Transport Layer**: Handles the communication mechanism between the SDK and CLI process (currently using process transport via `ProcessTransport`)
- **Protocol Layer**: Defines data structures for communication based on the CLI protocol
- **Utils**: Common utilities for concurrent execution, timeout handling, and error management
## Key Features
### Permission Modes
The SDK supports different permission modes for controlling tool execution:
- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation.
- **`plan`**: Blocks all write tools, instructing AI to present a plan first.
- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation.
- **`yolo`**: All tools execute automatically without confirmation.
### Session Event Consumers and Assistant Content Consumers
The SDK provides two key interfaces for handling events and content from the CLI:
#### SessionEventConsumers Interface
The `SessionEventConsumers` interface provides callbacks for different types of messages during a session:
- `onSystemMessage`: Handles system messages from the CLI (receives Session and SDKSystemMessage)
- `onResultMessage`: Handles result messages from the CLI (receives Session and SDKResultMessage)
- `onAssistantMessage`: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage)
- `onPartialAssistantMessage`: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage)
- `onUserMessage`: Handles user messages (receives Session and SDKUserMessage)
- `onOtherMessage`: Handles other types of messages (receives Session and String message)
- `onControlResponse`: Handles control responses (receives Session and CLIControlResponse)
- `onControlRequest`: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse)
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlRequest<CLIControlPermissionRequest>, returns Behavior)
#### AssistantContentConsumers Interface
The `AssistantContentConsumers` interface handles different types of content within assistant messages:
- `onText`: Handles text content (receives Session and TextAssistantContent)
- `onThinking`: Handles thinking content (receives Session and ThingkingAssistantContent)
- `onToolUse`: Handles tool use content (receives Session and ToolUseAssistantContent)
- `onToolResult`: Handles tool result content (receives Session and ToolResultAssistantContent)
- `onOtherContent`: Handles other content types (receives Session and AssistantContent)
- `onUsage`: Handles usage information (receives Session and AssistantUsage)
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlPermissionRequest, returns Behavior)
- `onOtherControlRequest`: Handles other control requests (receives Session and ControlRequestPayload, returns ControlResponsePayload)
#### Relationship Between the Interfaces
**Important Note on Event Hierarchy:**
- `SessionEventConsumers` is the **high-level** event processor that handles different message types (system, assistant, user, etc.)
- `AssistantContentConsumers` is the **low-level** content processor that handles different types of content within assistant messages (text, tools, thinking, etc.)
**Processor Relationship:**
- `SessionEventConsumers``AssistantContentConsumers` (SessionEventConsumers uses AssistantContentConsumers to process content within assistant messages)
**Event Derivation Relationships:**
- `onAssistantMessage``onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`, `onUsage`
- `onPartialAssistantMessage``onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`
- `onControlRequest``onPermissionRequest`, `onOtherControlRequest`
**Event Timeout Relationships:**
Each event handler method has a corresponding timeout method that allows customizing the timeout behavior for that specific event:
- `onSystemMessage``onSystemMessageTimeout`
- `onResultMessage``onResultMessageTimeout`
- `onAssistantMessage``onAssistantMessageTimeout`
- `onPartialAssistantMessage``onPartialAssistantMessageTimeout`
- `onUserMessage``onUserMessageTimeout`
- `onOtherMessage``onOtherMessageTimeout`
- `onControlResponse``onControlResponseTimeout`
- `onControlRequest``onControlRequestTimeout`
For AssistantContentConsumers timeout methods:
- `onText``onTextTimeout`
- `onThinking``onThinkingTimeout`
- `onToolUse``onToolUseTimeout`
- `onToolResult``onToolResultTimeout`
- `onOtherContent``onOtherContentTimeout`
- `onPermissionRequest``onPermissionRequestTimeout`
- `onOtherControlRequest``onOtherControlRequestTimeout`
**Default Timeout Values:**
- `SessionEventSimpleConsumers` default timeout: 180 seconds (Timeout.TIMEOUT_180_SECONDS)
- `AssistantContentSimpleConsumers` default timeout: 60 seconds (Timeout.TIMEOUT_60_SECONDS)
**Timeout Hierarchy Requirements:**
For proper operation, the following timeout relationships should be maintained:
- `onAssistantMessageTimeout` return value should be greater than `onTextTimeout`, `onThinkingTimeout`, `onToolUseTimeout`, `onToolResultTimeout`, and `onOtherContentTimeout` return values
- `onControlRequestTimeout` return value should be greater than `onPermissionRequestTimeout` and `onOtherControlRequestTimeout` return values
### Transport Options
The `TransportOptions` class allows configuration of how the SDK communicates with the Qwen Code CLI:
- `pathToQwenExecutable`: Path to the Qwen Code CLI executable
- `cwd`: Working directory for the CLI process
- `model`: AI model to use for the session
- `permissionMode`: Permission mode that controls tool execution
- `env`: Environment variables to pass to the CLI process
- `maxSessionTurns`: Limits the number of conversation turns in a session
- `coreTools`: List of core tools that should be available to the AI
- `excludeTools`: List of tools to exclude from being available to the AI
- `allowedTools`: List of tools that are pre-approved for use without additional confirmation
- `authType`: Authentication type to use for the session
- `includePartialMessages`: Enables receiving partial messages during streaming responses
- `skillsEnable`: Enables or disables skills functionality for the session
- `turnTimeout`: Timeout for a complete turn of conversation
- `messageTimeout`: Timeout for individual messages within a turn
- `resumeSessionId`: ID of a previous session to resume
- `otherOptions`: Additional command-line options to pass to the CLI
### Session Control Features
- **Session creation**: Use `QwenCodeCli.newSession()` to create a new session with custom options
- **Session management**: The `Session` class provides methods to send prompts, handle responses, and manage session state
- **Session cleanup**: Always close sessions using `session.close()` to properly terminate the CLI process
- **Session resumption**: Use `setResumeSessionId()` in `TransportOptions` to resume a previous session
- **Session interruption**: Use `session.interrupt()` to interrupt a currently running prompt
- **Dynamic model switching**: Use `session.setModel()` to change the model during a session
- **Dynamic permission mode switching**: Use `session.setPermissionMode()` to change the permission mode during a session
### Thread Pool Configuration
The SDK uses a thread pool for managing concurrent operations with the following default configuration:
- **Core Pool Size**: 30 threads
- **Maximum Pool Size**: 100 threads
- **Keep-Alive Time**: 60 seconds
- **Queue Capacity**: 300 tasks (using LinkedBlockingQueue)
- **Thread Naming**: "qwen_code_cli-pool-{number}"
- **Daemon Threads**: false
- **Rejected Execution Handler**: CallerRunsPolicy
## Error Handling
The SDK provides specific exception types for different error scenarios:
- `SessionControlException`: Thrown when there's an issue with session control (creation, initialization, etc.)
- `SessionSendPromptException`: Thrown when there's an issue sending a prompt or receiving a response
- `SessionClosedException`: Thrown when attempting to use a closed session
## FAQ / Troubleshooting
### Q: Do I need to install the Qwen CLI separately?
A: yes, requires Qwen CLI 0.5.5 or higher.
### Q: What Java versions are supported?
A: The SDK requires Java 1.8 or higher.
### Q: How do I handle long-running requests?
A: The SDK includes timeout utilities. You can configure timeouts using the `Timeout` class in `TransportOptions`.
### Q: Why are some tools not executing?
A: This is likely due to permission modes. Check your permission mode settings and consider using `allowedTools` to pre-approve certain tools.
### Q: How do I resume a previous session?
A: Use the `setResumeSessionId()` method in `TransportOptions` to resume a previous session.
### Q: Can I customize the environment for the CLI process?
A: Yes, use the `setEnv()` method in `TransportOptions` to pass environment variables to the CLI process.
## License
Apache-2.0 - see [LICENSE](./LICENSE) for details.

View File

@@ -124,107 +124,44 @@ Settings are organized into categories. All settings should be placed within the
"temperature": 0.2, "temperature": 0.2,
"top_p": 0.8, "top_p": 0.8,
"max_tokens": 1024 "max_tokens": 1024
},
"reasoning": {
"effort": "medium"
} }
} }
} }
} }
``` ```
**Reasoning Configuration:**
The `reasoning` field controls reasoning behavior for models that support it:
- Set to `false` to disable reasoning entirely
- Set to an object with `effort` field to enable reasoning with a specific effort level:
- `"low"`: Minimal reasoning effort
- `"medium"`: Balanced reasoning effort (default)
- `"high"`: Maximum reasoning effort
- Optionally include `budget_tokens` to limit reasoning token usage
Example to disable reasoning:
```json
{
"model": {
"generationConfig": {
"reasoning": false
}
}
}
```
**model.openAILoggingDir examples:** **model.openAILoggingDir examples:**
- `"~/qwen-logs"` - Logs to `~/qwen-logs` directory - `"~/qwen-logs"` - Logs to `~/qwen-logs` directory
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory - `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs` - `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs`
#### modelProviders
Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, `vertex-ai`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden.
##### Example
```json
{
"modelProviders": {
"openai": [
{
"id": "gpt-4o",
"name": "GPT-4o",
"envKey": "OPENAI_API_KEY",
"baseUrl": "https://api.openai.com/v1",
"generationConfig": {
"timeout": 60000,
"maxRetries": 3,
"samplingParams": { "temperature": 0.2 }
}
}
],
"anthropic": [
{
"id": "claude-3-5-sonnet",
"envKey": "ANTHROPIC_API_KEY",
"baseUrl": "https://api.anthropic.com/v1"
}
],
"gemini": [
{
"id": "gemini-2.0-flash",
"name": "Gemini 2.0 Flash",
"envKey": "GEMINI_API_KEY",
"baseUrl": "https://generativelanguage.googleapis.com"
}
],
"vertex-ai": [
{
"id": "gemini-1.5-pro-vertex",
"envKey": "GOOGLE_API_KEY",
"baseUrl": "https://generativelanguage.googleapis.com"
}
]
}
}
```
> [!note]
> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows.
##### Resolution layers and atomicity
The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers.
| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy |
| -------------------------- | ----------------------------------- | ----------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------- | ---------------------- | --------------------------------- |
| Programmatic overrides | `/auth ` | `/auth` input | `/auth` input | `/auth` input | — | — |
| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — |
| CLI arguments | `--auth-type` | `--model` | `--openaiApiKey` (or provider-specific equivalents) | `--openaiBaseUrl` (or provider-specific equivalents) | — | — |
| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — |
| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — |
| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3-coder-plus`) | — | — | — | `Config.getProxy()` if configured |
\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration.
Model-provider sourced values are applied atomically: once a provider model is active, every field it defines is protected from lower layers until you manually clear credentials via `/auth`. The final `generationConfig` is the projection across all layers—lower layers only fill gaps left by higher ones, and the provider layer remains impenetrable.
The merge strategy for `modelProviders` is REPLACE: the entire `modelProviders` from project settings will override the corresponding section in user settings, rather than merging the two.
##### Generation config layering
Per-field precedence for `generationConfig`:
1. Programmatic overrides (e.g. runtime `/model`, `/auth` changes)
2. `modelProviders[authType][].generationConfig`
3. `settings.model.generationConfig`
4. Content-generator defaults (`getDefaultGenerationConfig` for OpenAI, `getParameterValue` for Gemini, etc.)
`samplingParams` is treated atomically; provider values replace the entire object. Defaults from the content generator apply last so each provider retains its tuned baseline.
##### Selection persistence and recommendations
> [!important]
> Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope.
- `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog.
- Without `modelProviders`, the resolver mixes CLI/env/settings layers, which is fine for single-provider setups but cumbersome when frequently switching. Define provider catalogs whenever multi-model workflows are common so that switches stay atomic, source-attributed, and debuggable.
#### context #### context
| Setting | Type | Description | Default | | Setting | Type | Description | Default |
@@ -470,7 +407,7 @@ 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-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. | | `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | | | `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
| `--acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. | | `--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. | | `--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` | | `--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. | | | | `--list-extensions` | `-l` | Lists all available extensions and exits. | | |

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@
"Qwen Code": { "Qwen Code": {
"type": "custom", "type": "custom",
"command": "qwen", "command": "qwen",
"args": ["--acp"], "args": ["--experimental-acp"],
"env": {} "env": {}
} }
``` ```

View File

@@ -1,6 +1,5 @@
# Qwen Code overview # Qwen Code overview
[![@qwen-code/qwen-code downloads](https://img.shields.io/npm/dw/@qwen-code/qwen-code.svg)](https://npm-compare.com/@qwen-code/qwen-code)
[![@qwen-code/qwen-code downloads](https://img.shields.io/npm/dw/@qwen-code/qwen-code.svg)](https://npm-compare.com/@qwen-code/qwen-code)
[![@qwen-code/qwen-code version](https://img.shields.io/npm/v/@qwen-code/qwen-code.svg)](https://www.npmjs.com/package/@qwen-code/qwen-code) [![@qwen-code/qwen-code version](https://img.shields.io/npm/v/@qwen-code/qwen-code.svg)](https://www.npmjs.com/package/@qwen-code/qwen-code)
> Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before. > Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before.

View File

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

View File

@@ -80,11 +80,10 @@ type PermissionHandler = (
/** /**
* Sets up an ACP test environment with all necessary utilities. * Sets up an ACP test environment with all necessary utilities.
* @param useNewFlag - If true, uses --acp; if false, uses --experimental-acp (for backward compatibility testing)
*/ */
function setupAcpTest( function setupAcpTest(
rig: TestRig, rig: TestRig,
options?: { permissionHandler?: PermissionHandler; useNewFlag?: boolean }, options?: { permissionHandler?: PermissionHandler },
) { ) {
const pending = new Map<number, PendingRequest>(); const pending = new Map<number, PendingRequest>();
let nextRequestId = 1; let nextRequestId = 1;
@@ -96,13 +95,9 @@ function setupAcpTest(
const permissionHandler = const permissionHandler =
options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' })); options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' }));
// Use --acp by default, but allow testing with --experimental-acp for backward compatibility
const acpFlag =
options?.useNewFlag !== false ? '--acp' : '--experimental-acp';
const agent = spawn( const agent = spawn(
'node', 'node',
[rig.bundlePath, acpFlag, '--no-chat-recording'], [rig.bundlePath, '--experimental-acp', '--no-chat-recording'],
{ {
cwd: rig.testDir!, cwd: rig.testDir!,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
@@ -626,99 +621,3 @@ function setupAcpTest(
} }
}); });
}); });
(IS_SANDBOX ? describe.skip : describe)(
'acp flag backward compatibility',
() => {
it('should work with deprecated --experimental-acp flag and show warning', async () => {
const rig = new TestRig();
rig.setup('acp backward compatibility');
const { sendRequest, cleanup, stderr } = setupAcpTest(rig, {
useNewFlag: false,
});
try {
const initResult = await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
},
});
expect(initResult).toBeDefined();
// Verify deprecation warning is shown
const stderrOutput = stderr.join('');
expect(stderrOutput).toContain('--experimental-acp is deprecated');
expect(stderrOutput).toContain('Please use --acp instead');
await sendRequest('authenticate', { methodId: 'openai' });
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as { sessionId: string };
expect(newSession.sessionId).toBeTruthy();
// Verify functionality still works
const promptResult = await sendRequest('session/prompt', {
sessionId: newSession.sessionId,
prompt: [{ type: 'text', text: 'Say hello.' }],
});
expect(promptResult).toBeDefined();
} catch (e) {
if (stderr.length) {
console.error('Agent stderr:', stderr.join(''));
}
throw e;
} finally {
await cleanup();
}
});
it('should work with new --acp flag without warnings', async () => {
const rig = new TestRig();
rig.setup('acp new flag');
const { sendRequest, cleanup, stderr } = setupAcpTest(rig, {
useNewFlag: true,
});
try {
const initResult = await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
},
});
expect(initResult).toBeDefined();
// Verify no deprecation warning is shown
const stderrOutput = stderr.join('');
expect(stderrOutput).not.toContain('--experimental-acp is deprecated');
await sendRequest('authenticate', { methodId: 'openai' });
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as { sessionId: string };
expect(newSession.sessionId).toBeTruthy();
// Verify functionality works
const promptResult = await sendRequest('session/prompt', {
sessionId: newSession.sessionId,
prompt: [{ type: 'text', text: 'Say hello.' }],
});
expect(promptResult).toBeDefined();
} catch (e) {
if (stderr.length) {
console.error('Agent stderr:', stderr.join(''));
}
throw e;
} finally {
await cleanup();
}
});
},
);

View File

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

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.7.0", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.7.0", "version": "0.6.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -17316,7 +17316,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.7.0", "version": "0.6.0",
"dependencies": { "dependencies": {
"@google/genai": "1.30.0", "@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
@@ -17953,7 +17953,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@qwen-code/qwen-code-core", "name": "@qwen-code/qwen-code-core",
"version": "0.7.0", "version": "0.6.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.36.1", "@anthropic-ai/sdk": "^0.36.1",
@@ -21413,7 +21413,7 @@
}, },
"packages/test-utils": { "packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils", "name": "@qwen-code/qwen-code-test-utils",
"version": "0.7.0", "version": "0.6.0",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
@@ -21425,7 +21425,7 @@
}, },
"packages/vscode-ide-companion": { "packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion", "name": "qwen-code-vscode-ide-companion",
"version": "0.7.0", "version": "0.6.0",
"license": "LICENSE", "license": "LICENSE",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1", "@modelcontextprotocol/sdk": "^1.25.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.7.0", "version": "0.6.0",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git" "url": "git+https://github.com/QwenLM/qwen-code.git"
}, },
"config": { "config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0" "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
}, },
"scripts": { "scripts": {
"start": "cross-env node scripts/start.js", "start": "cross-env node scripts/start.js",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.7.0", "version": "0.6.0",
"description": "Qwen Code", "description": "Qwen Code",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -33,7 +33,7 @@
"dist" "dist"
], ],
"config": { "config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0" "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
}, },
"dependencies": { "dependencies": {
"@google/genai": "1.30.0", "@google/genai": "1.30.0",

View File

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

View File

@@ -15,6 +15,7 @@ import {
qwenOAuth2Events, qwenOAuth2Events,
MCPServerConfig, MCPServerConfig,
SessionService, SessionService,
buildApiHistoryFromConversation,
type Config, type Config,
type ConversationRecord, type ConversationRecord,
type DeviceAuthorizationData, type DeviceAuthorizationData,
@@ -348,20 +349,12 @@ class GeminiAgent {
const sessionId = config.getSessionId(); const sessionId = config.getSessionId();
const geminiClient = config.getGeminiClient(); const geminiClient = config.getGeminiClient();
// Use GeminiClient to manage chat lifecycle properly const history = conversation
// This ensures geminiClient.chat is in sync with the session's chat ? buildApiHistoryFromConversation(conversation)
// : undefined;
// Note: When loading a session, config.initialize() has already been called const chat = history
// in newSessionConfig(), which in turn calls geminiClient.initialize(). ? await geminiClient.startChat(history)
// The GeminiClient.initialize() method checks config.getResumedSessionData() : await geminiClient.startChat();
// and automatically loads the conversation history into the chat instance.
// So we only need to initialize if it hasn't been done yet.
if (!geminiClient.isInitialized()) {
await geminiClient.initialize();
}
// Now get the chat instance that's managed by GeminiClient
const chat = geminiClient.getChat();
const session = new Session( const session = new Session(
sessionId, sessionId,

View File

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

View File

@@ -1,112 +1,41 @@
/** /**
* @license * @license
* Copyright 2025 Qwen Team * Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { AuthType } from '@qwen-code/qwen-code-core'; import { AuthType } from '@qwen-code/qwen-code-core';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { validateAuthMethod } from './auth.js'; import { validateAuthMethod } from './auth.js';
import * as settings from './settings.js';
vi.mock('./settings.js', () => ({ vi.mock('./settings.js', () => ({
loadEnvironment: vi.fn(), loadEnvironment: vi.fn(),
loadSettings: vi.fn().mockReturnValue({ loadSettings: vi.fn().mockReturnValue({
merged: {}, merged: vi.fn().mockReturnValue({}),
}), }),
})); }));
describe('validateAuthMethod', () => { describe('validateAuthMethod', () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
// Reset mock to default
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {},
} as ReturnType<typeof settings.loadSettings>);
}); });
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
delete process.env['OPENAI_API_KEY'];
delete process.env['CUSTOM_API_KEY'];
delete process.env['GEMINI_API_KEY'];
delete process.env['GEMINI_API_KEY_ALTERED'];
delete process.env['ANTHROPIC_API_KEY'];
delete process.env['ANTHROPIC_BASE_URL'];
delete process.env['GOOGLE_API_KEY'];
}); });
it('should return null for USE_OPENAI with default env key', () => { it('should return null for USE_OPENAI', () => {
process.env['OPENAI_API_KEY'] = 'fake-key'; process.env['OPENAI_API_KEY'] = 'fake-key';
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull(); expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
}); });
it('should return an error message for USE_OPENAI if no API key is available', () => { it('should return an error message for USE_OPENAI if OPENAI_API_KEY is not set', () => {
delete process.env['OPENAI_API_KEY'];
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe( expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe(
"Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable.", 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.',
); );
}); });
it('should return null for USE_OPENAI with custom envKey from modelProviders', () => {
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'custom-model' },
modelProviders: {
openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
process.env['CUSTOM_API_KEY'] = 'custom-key';
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
});
it('should return error with custom envKey hint when modelProviders envKey is set but env var is missing', () => {
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'custom-model' },
modelProviders: {
openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
const result = validateAuthMethod(AuthType.USE_OPENAI);
expect(result).toContain('CUSTOM_API_KEY');
});
it('should return null for USE_GEMINI with custom envKey', () => {
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'gemini-1.5-flash' },
modelProviders: {
gemini: [
{ id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' },
],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
process.env['GEMINI_API_KEY_ALTERED'] = 'altered-key';
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
});
it('should return error with custom envKey for USE_GEMINI when env var is missing', () => {
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'gemini-1.5-flash' },
modelProviders: {
gemini: [
{ id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' },
],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
const result = validateAuthMethod(AuthType.USE_GEMINI);
expect(result).toContain('GEMINI_API_KEY_ALTERED');
});
it('should return null for QWEN_OAUTH', () => { it('should return null for QWEN_OAUTH', () => {
expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull(); expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull();
}); });
@@ -116,115 +45,4 @@ describe('validateAuthMethod', () => {
'Invalid auth method selected.', 'Invalid auth method selected.',
); );
}); });
it('should return null for USE_ANTHROPIC with custom envKey and baseUrl', () => {
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'claude-3' },
modelProviders: {
anthropic: [
{
id: 'claude-3',
envKey: 'CUSTOM_ANTHROPIC_KEY',
baseUrl: 'https://api.anthropic.com',
},
],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-anthropic-key';
expect(validateAuthMethod(AuthType.USE_ANTHROPIC)).toBeNull();
});
it('should return error for USE_ANTHROPIC when baseUrl is missing', () => {
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'claude-3' },
modelProviders: {
anthropic: [{ id: 'claude-3', envKey: 'CUSTOM_ANTHROPIC_KEY' }],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-key';
const result = validateAuthMethod(AuthType.USE_ANTHROPIC);
expect(result).toContain('modelProviders[].baseUrl');
});
it('should return null for USE_VERTEX_AI with custom envKey', () => {
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'vertex-model' },
modelProviders: {
'vertex-ai': [
{ id: 'vertex-model', envKey: 'GOOGLE_API_KEY_VERTEX' },
],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
process.env['GOOGLE_API_KEY_VERTEX'] = 'vertex-key';
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should use config.modelsConfig.getModel() when Config is provided', () => {
// Settings has a different model
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'settings-model' },
modelProviders: {
openai: [
{ id: 'settings-model', envKey: 'SETTINGS_API_KEY' },
{ id: 'cli-model', envKey: 'CLI_API_KEY' },
],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
// Mock Config object that returns a different model (e.g., from CLI args)
const mockConfig = {
modelsConfig: {
getModel: vi.fn().mockReturnValue('cli-model'),
},
} as unknown as import('@qwen-code/qwen-code-core').Config;
// Set the env key for the CLI model, not the settings model
process.env['CLI_API_KEY'] = 'cli-key';
// Should use 'cli-model' from config.modelsConfig.getModel(), not 'settings-model'
const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig);
expect(result).toBeNull();
expect(mockConfig.modelsConfig.getModel).toHaveBeenCalled();
});
it('should fail validation when Config provides different model without matching env key', () => {
// Clean up any existing env keys first
delete process.env['CLI_API_KEY'];
delete process.env['SETTINGS_API_KEY'];
delete process.env['OPENAI_API_KEY'];
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'settings-model' },
modelProviders: {
openai: [
{ id: 'settings-model', envKey: 'SETTINGS_API_KEY' },
{ id: 'cli-model', envKey: 'CLI_API_KEY' },
],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
const mockConfig = {
modelsConfig: {
getModel: vi.fn().mockReturnValue('cli-model'),
},
} as unknown as import('@qwen-code/qwen-code-core').Config;
// Don't set CLI_API_KEY - validation should fail
const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig);
expect(result).not.toBeNull();
expect(result).toContain('CLI_API_KEY');
});
}); });

View File

@@ -1,169 +1,21 @@
/** /**
* @license * @license
* Copyright 2025 Qwen Team * Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { import { AuthType } from '@qwen-code/qwen-code-core';
AuthType, import { loadEnvironment, loadSettings } from './settings.js';
type Config,
type ModelProvidersConfig,
type ProviderModelConfig,
} from '@qwen-code/qwen-code-core';
import { loadEnvironment, loadSettings, type Settings } from './settings.js';
import { t } from '../i18n/index.js';
/** export function validateAuthMethod(authMethod: string): string | null {
* Default environment variable names for each auth type
*/
const DEFAULT_ENV_KEYS: Record<string, string> = {
[AuthType.USE_OPENAI]: 'OPENAI_API_KEY',
[AuthType.USE_ANTHROPIC]: 'ANTHROPIC_API_KEY',
[AuthType.USE_GEMINI]: 'GEMINI_API_KEY',
[AuthType.USE_VERTEX_AI]: 'GOOGLE_API_KEY',
};
/**
* Find model configuration from modelProviders by authType and modelId
*/
function findModelConfig(
modelProviders: ModelProvidersConfig | undefined,
authType: string,
modelId: string | undefined,
): ProviderModelConfig | undefined {
if (!modelProviders || !modelId) {
return undefined;
}
const models = modelProviders[authType];
if (!Array.isArray(models)) {
return undefined;
}
return models.find((m) => m.id === modelId);
}
/**
* Check if API key is available for the given auth type and model configuration.
* Prioritizes custom envKey from modelProviders over default environment variables.
*/
function hasApiKeyForAuth(
authType: string,
settings: Settings,
config?: Config,
): {
hasKey: boolean;
checkedEnvKey: string | undefined;
isExplicitEnvKey: boolean;
} {
const modelProviders = settings.modelProviders as
| ModelProvidersConfig
| undefined;
// Use config.modelsConfig.getModel() if available for accurate model ID resolution
// that accounts for CLI args, env vars, and settings. Fall back to settings.model.name.
const modelId = config?.modelsConfig.getModel() ?? settings.model?.name;
// Try to find model-specific envKey from modelProviders
const modelConfig = findModelConfig(modelProviders, authType, modelId);
if (modelConfig?.envKey) {
// Explicit envKey configured - only check this env var, no apiKey fallback
const hasKey = !!process.env[modelConfig.envKey];
return {
hasKey,
checkedEnvKey: modelConfig.envKey,
isExplicitEnvKey: true,
};
}
// Using default environment variable - apiKey fallback is allowed
const defaultEnvKey = DEFAULT_ENV_KEYS[authType];
if (defaultEnvKey) {
const hasKey = !!process.env[defaultEnvKey];
if (hasKey) {
return { hasKey, checkedEnvKey: defaultEnvKey, isExplicitEnvKey: false };
}
}
// Also check settings.security.auth.apiKey as fallback (only for default env key)
if (settings.security?.auth?.apiKey) {
return {
hasKey: true,
checkedEnvKey: defaultEnvKey || undefined,
isExplicitEnvKey: false,
};
}
return {
hasKey: false,
checkedEnvKey: defaultEnvKey,
isExplicitEnvKey: false,
};
}
/**
* Generate API key error message based on auth check result.
* Returns null if API key is present, otherwise returns the appropriate error message.
*/
function getApiKeyError(
authMethod: string,
settings: Settings,
config?: Config,
): string | null {
const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth(
authMethod,
settings,
config,
);
if (hasKey) {
return null;
}
const envKeyHint = checkedEnvKey || DEFAULT_ENV_KEYS[authMethod];
if (isExplicitEnvKey) {
return t(
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.',
{ envKeyHint },
);
}
return t(
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.',
{ envKeyHint },
);
}
/**
* Validate that the required credentials and configuration exist for the given auth method.
*/
export function validateAuthMethod(
authMethod: string,
config?: Config,
): string | null {
const settings = loadSettings(); const settings = loadSettings();
loadEnvironment(settings.merged); loadEnvironment(settings.merged);
if (authMethod === AuthType.USE_OPENAI) { if (authMethod === AuthType.USE_OPENAI) {
const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth( const hasApiKey =
authMethod, process.env['OPENAI_API_KEY'] || settings.merged.security?.auth?.apiKey;
settings.merged, if (!hasApiKey) {
config, return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.';
);
if (!hasKey) {
const envKeyHint = checkedEnvKey
? `'${checkedEnvKey}'`
: "'OPENAI_API_KEY'";
if (isExplicitEnvKey) {
// Explicit envKey configured - only suggest setting the env var
return t(
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.',
{ envKeyHint },
);
}
// Default env key - can use either apiKey or env var
return t(
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.',
{ envKeyHint },
);
} }
return null; return null;
} }
@@ -175,49 +27,36 @@ export function validateAuthMethod(
} }
if (authMethod === AuthType.USE_ANTHROPIC) { if (authMethod === AuthType.USE_ANTHROPIC) {
const apiKeyError = getApiKeyError(authMethod, settings.merged, config); const hasApiKey = process.env['ANTHROPIC_API_KEY'];
if (apiKeyError) { if (!hasApiKey) {
return apiKeyError; return 'ANTHROPIC_API_KEY environment variable not found.';
} }
// Check baseUrl - can come from modelProviders or environment const hasBaseUrl = process.env['ANTHROPIC_BASE_URL'];
const modelProviders = settings.merged.modelProviders as if (!hasBaseUrl) {
| ModelProvidersConfig return 'ANTHROPIC_BASE_URL environment variable not found.';
| undefined;
// Use config.modelsConfig.getModel() if available for accurate model ID
const modelId =
config?.modelsConfig.getModel() ?? settings.merged.model?.name;
const modelConfig = findModelConfig(modelProviders, authMethod, modelId);
if (modelConfig && !modelConfig.baseUrl) {
return t(
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.',
);
}
if (!modelConfig && !process.env['ANTHROPIC_BASE_URL']) {
return t('ANTHROPIC_BASE_URL environment variable not found.');
} }
return null; return null;
} }
if (authMethod === AuthType.USE_GEMINI) { if (authMethod === AuthType.USE_GEMINI) {
const apiKeyError = getApiKeyError(authMethod, settings.merged, config); const hasApiKey = process.env['GEMINI_API_KEY'];
if (apiKeyError) { if (!hasApiKey) {
return apiKeyError; return 'GEMINI_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
} }
return null; return null;
} }
if (authMethod === AuthType.USE_VERTEX_AI) { if (authMethod === AuthType.USE_VERTEX_AI) {
const apiKeyError = getApiKeyError(authMethod, settings.merged, config); const hasApiKey = process.env['GOOGLE_API_KEY'];
if (apiKeyError) { if (!hasApiKey) {
return apiKeyError; return 'GOOGLE_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
} }
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
return null; return null;
} }
return t('Invalid auth method selected.'); return 'Invalid auth method selected.';
} }

View File

@@ -77,8 +77,10 @@ vi.mock('read-package-up', () => ({
), ),
})); }));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { vi.mock('@qwen-code/qwen-code-core', async () => {
const actualServer = await importOriginal<typeof ServerConfig>(); const actualServer = await vi.importActual<typeof ServerConfig>(
'@qwen-code/qwen-code-core',
);
return { return {
...actualServer, ...actualServer,
IdeClient: { IdeClient: {
@@ -1595,58 +1597,6 @@ describe('Approval mode tool exclusion logic', () => {
expect(excludedTools).toContain(WriteFileTool.Name); expect(excludedTools).toContain(WriteFileTool.Name);
}); });
it('should not exclude a tool explicitly allowed in tools.allowed', async () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
tools: {
allowed: [ShellTool.Name],
},
};
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
});
it('should not exclude a tool explicitly allowed in tools.core', async () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
tools: {
core: [ShellTool.Name],
},
};
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
});
it('should exclude only shell tools in non-interactive mode with auto-edit approval mode', async () => { it('should exclude only shell tools in non-interactive mode with auto-edit approval mode', async () => {
process.argv = [ process.argv = [
'node', 'node',

View File

@@ -10,31 +10,25 @@ import {
Config, Config,
DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_EMBEDDING_MODEL,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
EditTool,
FileDiscoveryService, FileDiscoveryService,
getCurrentGeminiMdFilename, getCurrentGeminiMdFilename,
loadServerHierarchicalMemory, loadServerHierarchicalMemory,
setGeminiMdFilename as setServerGeminiMdFilename, setGeminiMdFilename as setServerGeminiMdFilename,
ShellTool,
WriteFileTool,
resolveTelemetrySettings, resolveTelemetrySettings,
FatalConfigError, FatalConfigError,
Storage, Storage,
InputFormat, InputFormat,
OutputFormat, OutputFormat,
isToolEnabled,
SessionService, SessionService,
type ResumedSessionData, type ResumedSessionData,
type FileFilteringOptions, type FileFilteringOptions,
type MCPServerConfig, type MCPServerConfig,
type ToolName,
EditTool,
ShellTool,
WriteFileTool,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js'; import { extensionsCommand } from '../commands/extensions.js';
import type { Settings } from './settings.js'; import type { Settings } from './settings.js';
import {
resolveCliGenerationConfig,
getAuthTypeFromEnv,
} from '../utils/modelConfigUtils.js';
import yargs, { type Argv } from 'yargs'; import yargs, { type Argv } from 'yargs';
import { hideBin } from 'yargs/helpers'; import { hideBin } from 'yargs/helpers';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
@@ -117,7 +111,6 @@ export interface CliArgs {
telemetryOutfile: string | undefined; telemetryOutfile: string | undefined;
allowedMcpServerNames: string[] | undefined; allowedMcpServerNames: string[] | undefined;
allowedTools: string[] | undefined; allowedTools: string[] | undefined;
acp: boolean | undefined;
experimentalAcp: boolean | undefined; experimentalAcp: boolean | undefined;
experimentalSkills: boolean | undefined; experimentalSkills: boolean | undefined;
extensions: string[] | undefined; extensions: string[] | undefined;
@@ -311,15 +304,9 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
description: 'Enables checkpointing of file edits', description: 'Enables checkpointing of file edits',
default: false, default: false,
}) })
.option('acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('experimental-acp', { .option('experimental-acp', {
type: 'boolean', type: 'boolean',
description: description: 'Starts the agent in ACP mode',
'Starts the agent in ACP mode (deprecated, use --acp instead)',
hidden: true,
}) })
.option('experimental-skills', { .option('experimental-skills', {
type: 'boolean', type: 'boolean',
@@ -602,19 +589,8 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
// The import format is now only controlled by settings.memoryImportFormat // The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument // We no longer accept it as a CLI argument
// Handle deprecated --experimental-acp flag // Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
if (result['experimentalAcp']) { if (result['experimentalAcp'] && !result['channel']) {
console.warn(
'\x1b[33m⚠ Warning: --experimental-acp is deprecated and will be removed in a future release. Please use --acp instead.\x1b[0m',
);
// Map experimental-acp to acp if acp is not explicitly set
if (!result['acp']) {
(result as Record<string, unknown>)['acp'] = true;
}
}
// Apply ACP fallback: if acp or experimental-acp is present but no explicit --channel, treat as ACP
if ((result['acp'] || result['experimentalAcp']) && !result['channel']) {
(result as Record<string, unknown>)['channel'] = 'ACP'; (result as Record<string, unknown>)['channel'] = 'ACP';
} }
@@ -842,28 +818,6 @@ export async function loadCliConfig(
// However, if stream-json input is used, control can be requested via JSON messages, // However, if stream-json input is used, control can be requested via JSON messages,
// so tools should not be excluded in that case. // so tools should not be excluded in that case.
const extraExcludes: string[] = []; const extraExcludes: string[] = [];
const resolvedCoreTools = argv.coreTools || settings.tools?.core || [];
const resolvedAllowedTools =
argv.allowedTools || settings.tools?.allowed || [];
const isExplicitlyEnabled = (toolName: ToolName): boolean => {
if (resolvedCoreTools.length > 0) {
if (isToolEnabled(toolName, resolvedCoreTools, [])) {
return true;
}
}
if (resolvedAllowedTools.length > 0) {
if (isToolEnabled(toolName, resolvedAllowedTools, [])) {
return true;
}
}
return false;
};
const excludeUnlessExplicit = (toolName: ToolName): void => {
if (!isExplicitlyEnabled(toolName)) {
extraExcludes.push(toolName);
}
};
if ( if (
!interactive && !interactive &&
!argv.experimentalAcp && !argv.experimentalAcp &&
@@ -872,15 +826,12 @@ export async function loadCliConfig(
switch (approvalMode) { switch (approvalMode) {
case ApprovalMode.PLAN: case ApprovalMode.PLAN:
case ApprovalMode.DEFAULT: case ApprovalMode.DEFAULT:
// In default non-interactive mode, all tools that require approval are excluded, // In default non-interactive mode, all tools that require approval are excluded.
// unless explicitly enabled via coreTools/allowedTools. extraExcludes.push(ShellTool.Name, EditTool.Name, WriteFileTool.Name);
excludeUnlessExplicit(ShellTool.Name as ToolName);
excludeUnlessExplicit(EditTool.Name as ToolName);
excludeUnlessExplicit(WriteFileTool.Name as ToolName);
break; break;
case ApprovalMode.AUTO_EDIT: case ApprovalMode.AUTO_EDIT:
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded. // In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
excludeUnlessExplicit(ShellTool.Name as ToolName); extraExcludes.push(ShellTool.Name);
break; break;
case ApprovalMode.YOLO: case ApprovalMode.YOLO:
// No extra excludes for YOLO mode. // No extra excludes for YOLO mode.
@@ -928,25 +879,28 @@ export async function loadCliConfig(
const selectedAuthType = const selectedAuthType =
(argv.authType as AuthType | undefined) || (argv.authType as AuthType | undefined) ||
settings.security?.auth?.selectedType || settings.security?.auth?.selectedType;
/* getAuthTypeFromEnv means no authType was explicitly provided, we infer the authType from env vars */
getAuthTypeFromEnv();
// Unified resolution of generation config with source attribution const apiKey =
const resolvedCliConfig = resolveCliGenerationConfig({ (selectedAuthType === AuthType.USE_OPENAI
argv: { ? argv.openaiApiKey ||
model: argv.model, process.env['OPENAI_API_KEY'] ||
openaiApiKey: argv.openaiApiKey, settings.security?.auth?.apiKey
openaiBaseUrl: argv.openaiBaseUrl, : '') || '';
openaiLogging: argv.openaiLogging, const baseUrl =
openaiLoggingDir: argv.openaiLoggingDir, (selectedAuthType === AuthType.USE_OPENAI
}, ? argv.openaiBaseUrl ||
settings, process.env['OPENAI_BASE_URL'] ||
selectedAuthType, settings.security?.auth?.baseUrl
env: process.env as Record<string, string | undefined>, : '') || '';
}); const resolvedModel =
argv.model ||
const { model: resolvedModel } = resolvedCliConfig; (selectedAuthType === AuthType.USE_OPENAI
? process.env['OPENAI_MODEL'] ||
process.env['QWEN_MODEL'] ||
settings.model?.name
: '') ||
'';
const sandboxConfig = await loadSandboxConfig(settings, argv); const sandboxConfig = await loadSandboxConfig(settings, argv);
const screenReader = const screenReader =
@@ -980,8 +934,6 @@ export async function loadCliConfig(
} }
} }
const modelProvidersConfig = settings.modelProviders;
return new Config({ return new Config({
sessionId, sessionId,
sessionData, sessionData,
@@ -1029,7 +981,7 @@ export async function loadCliConfig(
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1, sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
maxSessionTurns: maxSessionTurns:
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1, argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false, experimentalZedIntegration: argv.experimentalAcp || false,
experimentalSkills: argv.experimentalSkills || false, experimentalSkills: argv.experimentalSkills || false,
listExtensions: argv.listExtensions || false, listExtensions: argv.listExtensions || false,
extensions: allExtensions, extensions: allExtensions,
@@ -1039,11 +991,24 @@ export async function loadCliConfig(
inputFormat, inputFormat,
outputFormat, outputFormat,
includePartialMessages, includePartialMessages,
modelProvidersConfig, generationConfig: {
generationConfigSources: resolvedCliConfig.sources, ...(settings.model?.generationConfig || {}),
generationConfig: resolvedCliConfig.generationConfig, model: resolvedModel,
apiKey,
baseUrl,
enableOpenAILogging:
(typeof argv.openaiLogging === 'undefined'
? settings.model?.enableOpenAILogging
: argv.openaiLogging) ?? false,
openAILoggingDir:
argv.openaiLoggingDir || settings.model?.openAILoggingDir,
},
cliVersion: await getCliVersion(), cliVersion: await getCliVersion(),
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType), webSearch: buildWebSearchConfig(
argv,
settings,
settings.security?.auth?.selectedType,
),
summarizeToolOutput: settings.model?.summarizeToolOutput, summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode, ideMode,
chatCompression: settings.model?.chatCompression, chatCompression: settings.model?.chatCompression,

View File

@@ -1,87 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { SettingScope } from './settings.js';
import { getPersistScopeForModelSelection } from './modelProvidersScope.js';
function makeSettings({
isTrusted,
userModelProviders,
workspaceModelProviders,
}: {
isTrusted: boolean;
userModelProviders?: unknown;
workspaceModelProviders?: unknown;
}) {
const userSettings: Record<string, unknown> = {};
const workspaceSettings: Record<string, unknown> = {};
// When undefined, treat as "not present in this scope" (the key is omitted),
// matching how LoadedSettings is shaped when a settings file doesn't define it.
if (userModelProviders !== undefined) {
userSettings['modelProviders'] = userModelProviders;
}
if (workspaceModelProviders !== undefined) {
workspaceSettings['modelProviders'] = workspaceModelProviders;
}
return {
isTrusted,
user: { settings: userSettings },
workspace: { settings: workspaceSettings },
} as unknown as import('./settings.js').LoadedSettings;
}
describe('getPersistScopeForModelSelection', () => {
it('prefers workspace when trusted and workspace defines modelProviders', () => {
const settings = makeSettings({
isTrusted: true,
workspaceModelProviders: {},
userModelProviders: { anything: true },
});
expect(getPersistScopeForModelSelection(settings)).toBe(
SettingScope.Workspace,
);
});
it('falls back to user when workspace does not define modelProviders', () => {
const settings = makeSettings({
isTrusted: true,
workspaceModelProviders: undefined,
userModelProviders: {},
});
expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User);
});
it('ignores workspace modelProviders when workspace is untrusted', () => {
const settings = makeSettings({
isTrusted: false,
workspaceModelProviders: {},
userModelProviders: undefined,
});
expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User);
});
it('falls back to legacy trust heuristic when neither scope defines modelProviders', () => {
const trusted = makeSettings({
isTrusted: true,
userModelProviders: undefined,
workspaceModelProviders: undefined,
});
expect(getPersistScopeForModelSelection(trusted)).toBe(SettingScope.User);
const untrusted = makeSettings({
isTrusted: false,
userModelProviders: undefined,
workspaceModelProviders: undefined,
});
expect(getPersistScopeForModelSelection(untrusted)).toBe(SettingScope.User);
});
});

View File

@@ -1,48 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { SettingScope, type LoadedSettings } from './settings.js';
function hasOwnModelProviders(settingsObj: unknown): boolean {
if (!settingsObj || typeof settingsObj !== 'object') {
return false;
}
const obj = settingsObj as Record<string, unknown>;
// Treat an explicitly configured empty object (modelProviders: {}) as "owned"
// by this scope, which is important when mergeStrategy is REPLACE.
return Object.prototype.hasOwnProperty.call(obj, 'modelProviders');
}
/**
* Returns which writable scope (Workspace/User) owns the effective modelProviders
* configuration.
*
* Note: Workspace scope is only considered when the workspace is trusted.
*/
export function getModelProvidersOwnerScope(
settings: LoadedSettings,
): SettingScope | undefined {
if (settings.isTrusted && hasOwnModelProviders(settings.workspace.settings)) {
return SettingScope.Workspace;
}
if (hasOwnModelProviders(settings.user.settings)) {
return SettingScope.User;
}
return undefined;
}
/**
* Choose the settings scope to persist a model selection.
* Prefer persisting back to the scope that contains the effective modelProviders
* config, otherwise fall back to the legacy trust-based heuristic.
*/
export function getPersistScopeForModelSelection(
settings: LoadedSettings,
): SettingScope {
return getModelProvidersOwnerScope(settings) ?? SettingScope.User;
}

View File

@@ -10,7 +10,6 @@ import type {
TelemetrySettings, TelemetrySettings,
AuthType, AuthType,
ChatCompressionSettings, ChatCompressionSettings,
ModelProvidersConfig,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { import {
ApprovalMode, ApprovalMode,
@@ -103,19 +102,6 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.SHALLOW_MERGE, mergeStrategy: MergeStrategy.SHALLOW_MERGE,
}, },
// Model providers configuration grouped by authType
modelProviders: {
type: 'object',
label: 'Model Providers',
category: 'Model',
requiresRestart: false,
default: {} as ModelProvidersConfig,
description:
'Model providers configuration grouped by authType. Each authType contains an array of model configurations.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
},
general: { general: {
type: 'object', type: 'object',
label: 'General', label: 'General',
@@ -216,7 +202,6 @@ const SETTINGS_SCHEMA = {
{ value: 'en', label: 'English' }, { value: 'en', label: 'English' },
{ value: 'zh', label: '中文 (Chinese)' }, { value: 'zh', label: '中文 (Chinese)' },
{ value: 'ru', label: 'Русский (Russian)' }, { value: 'ru', label: 'Русский (Russian)' },
{ value: 'de', label: 'Deutsch (German)' },
], ],
}, },
terminalBell: { terminalBell: {
@@ -690,6 +675,45 @@ const SETTINGS_SCHEMA = {
{ value: 'openapi_30', label: 'OpenAPI 3.0 Strict' }, { value: 'openapi_30', label: 'OpenAPI 3.0 Strict' },
], ],
}, },
samplingParams: {
type: 'object',
label: 'Sampling Parameters',
category: 'Generation Configuration',
requiresRestart: false,
default: undefined as
| {
top_p?: number;
top_k?: number;
repetition_penalty?: number;
presence_penalty?: number;
frequency_penalty?: number;
temperature?: number;
max_tokens?: number;
}
| undefined,
description: 'Sampling parameters for content generation.',
parentKey: 'generationConfig',
childKey: 'samplingParams',
showInDialog: false,
},
reasoning: {
type: 'object',
label: 'Reasoning Configuration',
category: 'Generation Configuration',
requiresRestart: false,
default: undefined as
| false
| {
effort?: 'low' | 'medium' | 'high';
budget_tokens?: number;
}
| undefined,
description:
'Reasoning configuration for models that support reasoning. Set to false to disable reasoning, or provide an object with effort level and optional token budget.',
parentKey: 'generationConfig',
childKey: 'reasoning',
showInDialog: false,
},
}, },
}, },
}, },

View File

@@ -15,7 +15,6 @@ import { type LoadedSettings, SettingScope } from '../config/settings.js';
import { performInitialAuth } from './auth.js'; import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js'; import { validateTheme } from './theme.js';
import { initializeI18n } from '../i18n/index.js'; import { initializeI18n } from '../i18n/index.js';
import { initializeLlmOutputLanguage } from '../ui/commands/languageCommand.js';
export interface InitializationResult { export interface InitializationResult {
authError: string | null; authError: string | null;
@@ -42,12 +41,7 @@ export async function initializeApp(
'auto'; 'auto';
await initializeI18n(languageSetting); await initializeI18n(languageSetting);
// Auto-detect and set LLM output language on first use const authType = settings.merged.security?.auth?.selectedType;
initializeLlmOutputLanguage();
// Use authType from modelsConfig which respects CLI --auth-type argument
// over settings.security.auth.selectedType
const authType = config.modelsConfig.getCurrentAuthType();
const authError = await performInitialAuth(config, authType); const authError = await performInitialAuth(config, authType);
// Fallback to user select when initial authentication fails // Fallback to user select when initial authentication fails
@@ -61,7 +55,7 @@ export async function initializeApp(
const themeError = validateTheme(settings); const themeError = validateTheme(settings);
const shouldOpenAuthDialog = const shouldOpenAuthDialog =
!config.modelsConfig.wasAuthTypeExplicitlyProvided() || !!authError; settings.merged.security?.auth?.selectedType === undefined || !!authError;
if (config.getIdeMode()) { if (config.getIdeMode()) {
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();

View File

@@ -87,15 +87,6 @@ vi.mock('./config/sandboxConfig.js', () => ({
loadSandboxConfig: vi.fn(), loadSandboxConfig: vi.fn(),
})); }));
vi.mock('./core/initializer.js', () => ({
initializeApp: vi.fn().mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
}),
}));
describe('gemini.tsx main function', () => { describe('gemini.tsx main function', () => {
let originalEnvGeminiSandbox: string | undefined; let originalEnvGeminiSandbox: string | undefined;
let originalEnvSandbox: string | undefined; let originalEnvSandbox: string | undefined;
@@ -371,6 +362,7 @@ describe('gemini.tsx main function', () => {
expect(inputArg).toBe('hello stream'); expect(inputArg).toBe('hello stream');
expect(validateAuthSpy).toHaveBeenCalledWith( expect(validateAuthSpy).toHaveBeenCalledWith(
undefined,
undefined, undefined,
configStub, configStub,
expect.any(Object), expect.any(Object),
@@ -468,7 +460,6 @@ describe('gemini.tsx main function kitty protocol', () => {
telemetryOutfile: undefined, telemetryOutfile: undefined,
allowedMcpServerNames: undefined, allowedMcpServerNames: undefined,
allowedTools: undefined, allowedTools: undefined,
acp: undefined,
experimentalAcp: undefined, experimentalAcp: undefined,
experimentalSkills: undefined, experimentalSkills: undefined,
extensions: undefined, extensions: undefined,
@@ -648,37 +639,4 @@ describe('startInteractiveUI', () => {
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
expect(checkForUpdates).toHaveBeenCalledTimes(1); expect(checkForUpdates).toHaveBeenCalledTimes(1);
}); });
it('should not check for updates when update nag is disabled', async () => {
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
const mockInitializationResult = {
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
};
const settingsWithUpdateNagDisabled = {
merged: {
general: {
disableUpdateNag: true,
},
ui: {
hideWindowTitle: false,
},
},
} as LoadedSettings;
await startInteractiveUI(
mockConfig,
settingsWithUpdateNagDisabled,
mockStartupWarnings,
mockWorkspaceRoot,
mockInitializationResult,
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(checkForUpdates).not.toHaveBeenCalled();
});
}); });

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { Config } from '@qwen-code/qwen-code-core'; import type { Config, AuthType } from '@qwen-code/qwen-code-core';
import { InputFormat, logUserPrompt } from '@qwen-code/qwen-code-core'; import { InputFormat, logUserPrompt } from '@qwen-code/qwen-code-core';
import { render } from 'ink'; import { render } from 'ink';
import dns from 'node:dns'; import dns from 'node:dns';
@@ -183,18 +183,16 @@ export async function startInteractiveUI(
}, },
); );
if (!settings.merged.general?.disableUpdateNag) { checkForUpdates()
checkForUpdates() .then((info) => {
.then((info) => { handleAutoUpdate(info, settings, config.getProjectRoot());
handleAutoUpdate(info, settings, config.getProjectRoot()); })
}) .catch((err) => {
.catch((err) => { // Silently ignore update check errors.
// Silently ignore update check errors. if (config.getDebugMode()) {
if (config.getDebugMode()) { console.error('Update check failed:', err);
console.error('Update check failed:', err); }
} });
});
}
registerCleanup(() => instance.unmount()); registerCleanup(() => instance.unmount());
} }
@@ -252,20 +250,22 @@ export async function main() {
argv, argv,
); );
if (!settings.merged.security?.auth?.useExternal) { if (
settings.merged.security?.auth?.selectedType &&
!settings.merged.security?.auth?.useExternal
) {
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect. // Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
try { try {
const authType = partialConfig.modelsConfig.getCurrentAuthType(); const err = validateAuthMethod(
// Fresh users may not have selected/persisted an authType yet. settings.merged.security.auth.selectedType,
// In that case, defer auth prompting/selection to the main interactive flow. );
if (authType) { if (err) {
const err = validateAuthMethod(authType, partialConfig); throw new Error(err);
if (err) {
throw new Error(err);
}
await partialConfig.refreshAuth(authType);
} }
await partialConfig.refreshAuth(
settings.merged.security.auth.selectedType,
);
} catch (err) { } catch (err) {
console.error('Error authenticating:', err); console.error('Error authenticating:', err);
process.exit(1); process.exit(1);
@@ -438,6 +438,8 @@ export async function main() {
} }
const nonInteractiveConfig = await validateNonInteractiveAuth( const nonInteractiveConfig = await validateNonInteractiveAuth(
(argv.authType as AuthType) ||
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal, settings.merged.security?.auth?.useExternal,
config, config,
settings, settings,

View File

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

View File

@@ -1,48 +0,0 @@
/**
* @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';
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -88,9 +88,6 @@ export default {
'No tools available': '没有可用工具', 'No tools available': '没有可用工具',
'View or change the approval mode for tool usage': 'View or change the approval mode for tool usage':
'查看或更改工具使用的审批模式', '查看或更改工具使用的审批模式',
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
'无效的审批模式 "{{arg}}"。有效模式:{{modes}}',
'Approval mode set to "{{mode}}"': '审批模式已设置为 "{{mode}}"',
'View or change the language setting': '查看或更改语言设置', 'View or change the language setting': '查看或更改语言设置',
'change the theme': '更改主题', 'change the theme': '更改主题',
'Select Theme': '选择主题', 'Select Theme': '选择主题',
@@ -104,7 +101,7 @@ export default {
'Theme "{{themeName}}" not found.': '未找到主题 "{{themeName}}"。', 'Theme "{{themeName}}" not found.': '未找到主题 "{{themeName}}"。',
'Theme "{{themeName}}" not found in selected scope.': 'Theme "{{themeName}}" not found in selected scope.':
'在所选作用域中未找到主题 "{{themeName}}"。', '在所选作用域中未找到主题 "{{themeName}}"。',
'Clear conversation history and free up context': '清除对话历史并释放上下文', 'clear the screen and conversation history': '清屏并清除对话历史',
'Compresses the context by replacing it with a summary.': 'Compresses the context by replacing it with a summary.':
'通过用摘要替换来压缩上下文', '通过用摘要替换来压缩上下文',
'open full Qwen Code documentation in your browser': 'open full Qwen Code documentation in your browser':
@@ -252,8 +249,6 @@ export default {
', Tab to change focus': 'Tab 切换焦点', ', Tab to change focus': 'Tab 切换焦点',
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
'要查看更改,必须重启 Qwen Code。按 r 退出并立即应用更改。', '要查看更改,必须重启 Qwen Code。按 r 退出并立即应用更改。',
'The command "/{{command}}" is not supported in non-interactive mode.':
'不支持在非交互模式下使用命令 "/{{command}}"。',
// ============================================================================ // ============================================================================
// Settings Labels // Settings Labels
// ============================================================================ // ============================================================================
@@ -565,12 +560,6 @@ export default {
'No conversation found to summarize.': '未找到要总结的对话', 'No conversation found to summarize.': '未找到要总结的对话',
'Failed to generate project context summary: {{error}}': 'Failed to generate project context summary: {{error}}':
'生成项目上下文摘要失败:{{error}}', '生成项目上下文摘要失败:{{error}}',
'Saved project summary to {{filePathForDisplay}}.':
'项目摘要已保存到 {{filePathForDisplay}}',
'Saving project summary...': '正在保存项目摘要...',
'Generating project summary...': '正在生成项目摘要...',
'Failed to generate summary - no text content received from LLM response':
'生成摘要失败 - 未从 LLM 响应中接收到文本内容',
// ============================================================================ // ============================================================================
// Commands - Model // Commands - Model
@@ -584,9 +573,8 @@ export default {
// ============================================================================ // ============================================================================
// Commands - Clear // Commands - Clear
// ============================================================================ // ============================================================================
'Starting a new session, resetting chat, and clearing terminal.': 'Clearing terminal and resetting chat.': '正在清屏并重置聊天',
'正在开始新会话,重置聊天并清屏', 'Clearing terminal.': '正在清屏',
'Starting a new session and clearing.': '正在开始新会话并清屏。',
// ============================================================================ // ============================================================================
// Commands - Compress // Commands - Compress
@@ -728,21 +716,6 @@ export default {
'Authentication timed out. Please try again.': '认证超时。请重试。', 'Authentication timed out. Please try again.': '认证超时。请重试。',
'Waiting for auth... (Press ESC or CTRL+C to cancel)': 'Waiting for auth... (Press ESC or CTRL+C to cancel)':
'正在等待认证...(按 ESC 或 CTRL+C 取消)', '正在等待认证...(按 ESC 或 CTRL+C 取消)',
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.':
'缺少 OpenAI 兼容认证的 API 密钥。请设置 settings.security.auth.apiKey 或设置 {{envKeyHint}} 环境变量。',
'{{envKeyHint}} environment variable not found.':
'未找到 {{envKeyHint}} 环境变量。',
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.':
'未找到 {{envKeyHint}} 环境变量。请在 .env 文件或系统环境变量中进行设置。',
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.':
'未找到 {{envKeyHint}} 环境变量(或设置 settings.security.auth.apiKey。请在 .env 文件或系统环境变量中进行设置。',
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.':
'缺少 OpenAI 兼容认证的 API 密钥。请设置 {{envKeyHint}} 环境变量。',
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.':
'Anthropic 提供商缺少必需的 baseUrl请在 modelProviders[].baseUrl 中配置。',
'ANTHROPIC_BASE_URL environment variable not found.':
'未找到 ANTHROPIC_BASE_URL 环境变量。',
'Invalid auth method selected.': '选择了无效的认证方式。',
'Failed to authenticate. Message: {{message}}': '认证失败。消息:{{message}}', 'Failed to authenticate. Message: {{message}}': '认证失败。消息:{{message}}',
'Authenticated successfully with {{authType}} credentials.': 'Authenticated successfully with {{authType}} credentials.':
'使用 {{authType}} 凭据成功认证。', '使用 {{authType}} 凭据成功认证。',
@@ -762,15 +735,6 @@ export default {
// ============================================================================ // ============================================================================
'Select Model': '选择模型', 'Select Model': '选择模型',
'(Press Esc to close)': '(按 Esc 关闭)', '(Press Esc to close)': '(按 Esc 关闭)',
'Current (effective) configuration': '当前(实际生效)配置',
AuthType: '认证方式',
'API Key': 'API 密钥',
unset: '未设置',
'(default)': '(默认)',
'(set)': '(已设置)',
'(not set)': '(未设置)',
"Failed to switch model to '{{modelId}}'.\n\n{{error}}":
"无法切换到模型 '{{modelId}}'.\n\n{{error}}",
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
'来自阿里云 ModelStudio 的最新 Qwen Coder 模型版本qwen3-coder-plus-2025-09-23', '来自阿里云 ModelStudio 的最新 Qwen Coder 模型版本qwen3-coder-plus-2025-09-23',
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
@@ -916,39 +880,165 @@ export default {
// ============================================================================ // ============================================================================
'Waiting for user confirmation...': '等待用户确认...', 'Waiting for user confirmation...': '等待用户确认...',
'(esc to cancel, {{time}})': '(按 esc 取消,{{time}}', '(esc to cancel, {{time}})': '(按 esc 取消,{{time}}',
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...': '正在预热 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...': '稍等片刻,正在优化幽默感...',
'只要我不写代码,代码就没有 Bug...', 'Shuffling punchlines...': '正在洗牌笑点...',
'正在把 Bug 转化为 Feature...', 'Untangling neural nets...': '正在解开神经网络...',
'只要我不尴尬Bug 就追不上我...', '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...':
'别急,美好的事物总是需要一点时间去酝酿...', '正在解决依赖关系...和存在主义危机...',
'愿你的代码永无 Bug愿你的梦想终将成真...', 'Defragmenting memories... both RAM and personal...':
'哪怕只有 0.1% 的进度,也是在向目标靠近...', '正在整理记忆碎片...包括 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...': '正在建造额外的能量塔...',
}; };

View File

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

View File

@@ -630,67 +630,6 @@ describe('BaseJsonOutputAdapter', () => {
expect(state.blocks).toHaveLength(0); expect(state.blocks).toHaveLength(0);
}); });
it('should preserve whitespace in thinking content', () => {
const state = adapter.exposeCreateMessageState();
adapter.startAssistantMessage();
adapter.exposeAppendThinking(
state,
'',
'The user just said "Hello"',
null,
);
expect(state.blocks).toHaveLength(1);
expect(state.blocks[0]).toMatchObject({
type: 'thinking',
thinking: 'The user just said "Hello"',
});
// Verify spaces are preserved
const block = state.blocks[0] as { thinking: string };
expect(block.thinking).toContain('user just');
expect(block.thinking).not.toContain('userjust');
});
it('should preserve whitespace when appending multiple thinking fragments', () => {
const state = adapter.exposeCreateMessageState();
adapter.startAssistantMessage();
// Simulate streaming thinking content in fragments
adapter.exposeAppendThinking(state, '', 'The user just', null);
adapter.exposeAppendThinking(state, '', ' said "Hello"', null);
adapter.exposeAppendThinking(
state,
'',
'. This is a simple greeting',
null,
);
expect(state.blocks).toHaveLength(1);
const block = state.blocks[0] as { thinking: string };
// Verify the complete text with all spaces preserved
expect(block.thinking).toBe(
'The user just said "Hello". This is a simple greeting',
);
// Verify specific space preservation
expect(block.thinking).toContain('user just ');
expect(block.thinking).toContain(' said');
expect(block.thinking).toContain('". This');
expect(block.thinking).not.toContain('userjust');
expect(block.thinking).not.toContain('justsaid');
});
it('should preserve leading and trailing whitespace in description', () => {
const state = adapter.exposeCreateMessageState();
adapter.startAssistantMessage();
adapter.exposeAppendThinking(state, '', ' content with spaces ', null);
expect(state.blocks).toHaveLength(1);
const block = state.blocks[0] as { thinking: string };
expect(block.thinking).toBe(' content with spaces ');
});
}); });
describe('appendToolUse', () => { describe('appendToolUse', () => {

View File

@@ -816,18 +816,9 @@ export abstract class BaseJsonOutputAdapter {
parentToolUseId?: string | null, parentToolUseId?: string | null,
): void { ): void {
const actualParentToolUseId = parentToolUseId ?? null; const actualParentToolUseId = parentToolUseId ?? null;
const fragment = [subject?.trim(), description?.trim()]
// Build fragment without trimming to preserve whitespace in streaming content .filter((value) => value && value.length > 0)
// Only filter out null/undefined/empty values .join(': ');
const parts: string[] = [];
if (subject && subject.length > 0) {
parts.push(subject);
}
if (description && description.length > 0) {
parts.push(description);
}
const fragment = parts.join(': ');
if (!fragment) { if (!fragment) {
return; return;
} }

View File

@@ -323,68 +323,6 @@ describe('StreamJsonOutputAdapter', () => {
}); });
}); });
it('should preserve whitespace in thinking content (issue #1356)', () => {
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: '',
description: 'The user just said "Hello"',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
const block = message.message.content[0] as {
type: string;
thinking: string;
};
expect(block.type).toBe('thinking');
expect(block.thinking).toBe('The user just said "Hello"');
// Verify spaces are preserved
expect(block.thinking).toContain('user just');
expect(block.thinking).not.toContain('userjust');
});
it('should preserve whitespace when streaming multiple thinking fragments (issue #1356)', () => {
// Simulate streaming thinking content in multiple events
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: '',
description: 'The user just',
},
});
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: '',
description: ' said "Hello"',
},
});
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: '',
description: '. This is a simple greeting',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
const block = message.message.content[0] as {
type: string;
thinking: string;
};
expect(block.thinking).toBe(
'The user just said "Hello". This is a simple greeting',
);
// Verify specific spaces are preserved
expect(block.thinking).toContain('user just ');
expect(block.thinking).toContain(' said');
expect(block.thinking).not.toContain('userjust');
expect(block.thinking).not.toContain('justsaid');
});
it('should append tool use from ToolCallRequest events', () => { it('should append tool use from ToolCallRequest events', () => {
adapter.processEvent({ adapter.processEvent({
type: GeminiEventType.ToolCallRequest, type: GeminiEventType.ToolCallRequest,

View File

@@ -68,7 +68,6 @@ describe('runNonInteractive', () => {
let mockShutdownTelemetry: Mock; let mockShutdownTelemetry: Mock;
let consoleErrorSpy: MockInstance; let consoleErrorSpy: MockInstance;
let processStdoutSpy: MockInstance; let processStdoutSpy: MockInstance;
let processStderrSpy: MockInstance;
let mockGeminiClient: { let mockGeminiClient: {
sendMessageStream: Mock; sendMessageStream: Mock;
getChatRecordingService: Mock; getChatRecordingService: Mock;
@@ -87,9 +86,6 @@ describe('runNonInteractive', () => {
processStdoutSpy = vi processStdoutSpy = vi
.spyOn(process.stdout, 'write') .spyOn(process.stdout, 'write')
.mockImplementation(() => true); .mockImplementation(() => true);
processStderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
vi.spyOn(process, 'exit').mockImplementation((code) => { vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit(${code}) called`); throw new Error(`process.exit(${code}) called`);
}); });
@@ -143,8 +139,6 @@ describe('runNonInteractive', () => {
setModel: vi.fn(async (model: string) => { setModel: vi.fn(async (model: string) => {
currentModel = model; currentModel = model;
}), }),
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(false),
} as unknown as Config; } as unknown as Config;
mockSettings = { mockSettings = {
@@ -298,9 +292,7 @@ describe('runNonInteractive', () => {
mockConfig, mockConfig,
expect.objectContaining({ name: 'testTool' }), expect.objectContaining({ name: 'testTool' }),
expect.any(AbortSignal), expect.any(AbortSignal),
expect.objectContaining({ undefined,
outputUpdateHandler: expect.any(Function),
}),
); );
// Verify first call has isContinuation: false // Verify first call has isContinuation: false
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
@@ -773,52 +765,6 @@ describe('runNonInteractive', () => {
); );
}); });
it('should handle API errors in text mode and exit with error code', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.TEXT);
setupMetricsMock();
// Simulate an API error event (like 401 unauthorized)
const apiErrorEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.Error,
value: {
error: {
message: '401 Incorrect API key provided',
status: 401,
},
},
};
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents([apiErrorEvent]),
);
let thrownError: Error | null = null;
try {
await runNonInteractive(
mockConfig,
mockSettings,
'Test input',
'prompt-id-api-error',
);
// Should not reach here
expect.fail('Expected error to be thrown');
} catch (error) {
thrownError = error as Error;
}
// Should throw with the API error message
expect(thrownError).toBeTruthy();
expect(thrownError?.message).toContain('401');
expect(thrownError?.message).toContain('Incorrect API key provided');
// Verify error was written to stderr
expect(processStderrSpy).toHaveBeenCalled();
const stderrCalls = processStderrSpy.mock.calls;
const errorOutput = stderrCalls.map((call) => call[0]).join('');
expect(errorOutput).toContain('401');
expect(errorOutput).toContain('Incorrect API key provided');
});
it('should handle FatalInputError with custom exit code in JSON format', async () => { it('should handle FatalInputError with custom exit code in JSON format', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON);
setupMetricsMock(); setupMetricsMock();
@@ -906,7 +852,7 @@ describe('runNonInteractive', () => {
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command'); expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
}); });
it('should handle command that requires confirmation by returning early', async () => { it('should throw FatalInputError if a command requires confirmation', async () => {
const mockCommand = { const mockCommand = {
name: 'confirm', name: 'confirm',
description: 'a command that needs confirmation', description: 'a command that needs confirmation',
@@ -918,16 +864,15 @@ describe('runNonInteractive', () => {
}; };
mockGetCommands.mockReturnValue([mockCommand]); mockGetCommands.mockReturnValue([mockCommand]);
await runNonInteractive( await expect(
mockConfig, runNonInteractive(
mockSettings, mockConfig,
'/confirm', mockSettings,
'prompt-id-confirm', '/confirm',
); 'prompt-id-confirm',
),
// Should write error message to stderr ).rejects.toThrow(
expect(processStderrSpy).toHaveBeenCalledWith( 'Exiting due to a confirmation prompt requested by the command.',
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.\n',
); );
}); });
@@ -964,30 +909,7 @@ describe('runNonInteractive', () => {
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown'); expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
}); });
it('should handle known but unsupported slash commands like /help by returning early', async () => { it('should throw for unhandled command result types', async () => {
// Mock a built-in command that exists but is not in the allowed list
const mockHelpCommand = {
name: 'help',
description: 'Show help',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
};
mockGetCommands.mockReturnValue([mockHelpCommand]);
await runNonInteractive(
mockConfig,
mockSettings,
'/help',
'prompt-id-help',
);
// Should write error message to stderr
expect(processStderrSpy).toHaveBeenCalledWith(
'The command "/help" is not supported in non-interactive mode.\n',
);
});
it('should handle unhandled command result types by returning early with error', async () => {
const mockCommand = { const mockCommand = {
name: 'noaction', name: 'noaction',
description: 'unhandled type', description: 'unhandled type',
@@ -998,16 +920,15 @@ describe('runNonInteractive', () => {
}; };
mockGetCommands.mockReturnValue([mockCommand]); mockGetCommands.mockReturnValue([mockCommand]);
await runNonInteractive( await expect(
mockConfig, runNonInteractive(
mockSettings, mockConfig,
'/noaction', mockSettings,
'prompt-id-unhandled', '/noaction',
); 'prompt-id-unhandled',
),
// Should write error message to stderr ).rejects.toThrow(
expect(processStderrSpy).toHaveBeenCalledWith( 'Exiting due to command result that is not supported in non-interactive mode.',
'Unknown command result type: unhandled\n',
); );
}); });
@@ -1825,84 +1746,4 @@ describe('runNonInteractive', () => {
{ isContinuation: false }, { isContinuation: false },
); );
}); });
it('should print tool output to console in text mode (non-Task tools)', async () => {
// Test that tool output is printed to stdout in text mode
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-1',
name: 'run_in_terminal',
args: { command: 'npm outdated' },
isClientInitiated: false,
prompt_id: 'prompt-id-tool-output',
},
};
// Mock tool execution with outputUpdateHandler being called
mockCoreExecuteToolCall.mockImplementation(
async (_config, _request, _signal, options) => {
// Simulate tool calling outputUpdateHandler with output chunks
if (options?.outputUpdateHandler) {
options.outputUpdateHandler('tool-1', 'Package outdated\n');
options.outputUpdateHandler('tool-1', 'npm@1.0.0 -> npm@2.0.0\n');
}
return {
responseParts: [
{
functionResponse: {
id: 'tool-1',
name: 'run_in_terminal',
response: {
output: 'Package outdated\nnpm@1.0.0 -> npm@2.0.0',
},
},
},
],
};
},
);
const firstCallEvents: ServerGeminiStreamEvent[] = [
toolCallEvent,
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
},
];
const secondCallEvents: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Dependencies checked' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } },
},
];
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
await runNonInteractive(
mockConfig,
mockSettings,
'Check dependencies',
'prompt-id-tool-output',
);
// Verify that executeToolCall was called with outputUpdateHandler
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({ name: 'run_in_terminal' }),
expect.any(AbortSignal),
expect.objectContaining({
outputUpdateHandler: expect.any(Function),
}),
);
// Verify tool output was written to stdout
expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated\n');
expect(processStdoutSpy).toHaveBeenCalledWith('npm@1.0.0 -> npm@2.0.0\n');
expect(processStdoutSpy).toHaveBeenCalledWith('Dependencies checked');
});
}); });

View File

@@ -4,11 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core';
Config,
ToolCallRequestInfo,
ToolResultDisplay,
} from '@qwen-code/qwen-code-core';
import { isSlashCommand } from './ui/utils/commandUtils.js'; import { isSlashCommand } from './ui/utils/commandUtils.js';
import type { LoadedSettings } from './config/settings.js'; import type { LoadedSettings } from './config/settings.js';
import { import {
@@ -46,55 +42,6 @@ import {
computeUsageFromMetrics, computeUsageFromMetrics,
} from './utils/nonInteractiveHelpers.js'; } from './utils/nonInteractiveHelpers.js';
/**
* Emits a final message for slash command results.
* Note: systemMessage should already be emitted before calling this function.
*/
async function emitNonInteractiveFinalMessage(params: {
message: string;
isError: boolean;
adapter?: JsonOutputAdapterInterface;
config: Config;
startTimeMs: number;
}): Promise<void> {
const { message, isError, adapter, config } = params;
if (!adapter) {
// Text output mode: write directly to stdout/stderr
const target = isError ? process.stderr : process.stdout;
target.write(`${message}\n`);
return;
}
// JSON output mode: emit assistant message and result
// (systemMessage should already be emitted by caller)
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: message,
} as unknown as Parameters<JsonOutputAdapterInterface['processEvent']>[0]);
adapter.finalizeAssistantMessage();
const metrics = uiTelemetryService.getMetrics();
const usage = computeUsageFromMetrics(metrics);
const outputFormat = config.getOutputFormat();
const stats =
outputFormat === OutputFormat.JSON
? uiTelemetryService.getMetrics()
: undefined;
adapter.emitResult({
isError,
durationMs: Date.now() - params.startTimeMs,
apiDurationMs: 0,
numTurns: 0,
errorMessage: isError ? message : undefined,
usage,
stats,
summary: message,
});
}
/** /**
* Provides optional overrides for `runNonInteractive` execution. * Provides optional overrides for `runNonInteractive` execution.
* *
@@ -168,16 +115,6 @@ export async function runNonInteractive(
process.on('SIGINT', shutdownHandler); process.on('SIGINT', shutdownHandler);
process.on('SIGTERM', shutdownHandler); process.on('SIGTERM', shutdownHandler);
// Emit systemMessage first (always the first message in JSON mode)
if (adapter) {
const systemMessage = await buildSystemMessage(
config,
sessionId,
permissionMode,
);
adapter.emitMessage(systemMessage);
}
let initialPartList: PartListUnion | null = extractPartsFromUserMessage( let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
options.userMessage, options.userMessage,
); );
@@ -191,45 +128,10 @@ export async function runNonInteractive(
config, config,
settings, settings,
); );
switch (slashCommandResult.type) { if (slashCommandResult) {
case 'submit_prompt': // A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise. initialPartList = slashCommandResult as PartListUnion;
initialPartList = slashCommandResult.content; slashHandled = true;
slashHandled = true;
break;
case 'message': {
// systemMessage already emitted above
await emitNonInteractiveFinalMessage({
message: slashCommandResult.content,
isError: slashCommandResult.messageType === 'error',
adapter,
config,
startTimeMs: startTime,
});
return;
}
case 'stream_messages':
throw new FatalInputError(
'Stream messages mode is not supported in non-interactive CLI',
);
case 'unsupported': {
await emitNonInteractiveFinalMessage({
message: slashCommandResult.reason,
isError: true,
adapter,
config,
startTimeMs: startTime,
});
return;
}
case 'no_command':
break;
default: {
const _exhaustive: never = slashCommandResult;
throw new FatalInputError(
`Unhandled slash command result type: ${(_exhaustive as { type: string }).type}`,
);
}
} }
} }
@@ -261,6 +163,15 @@ export async function runNonInteractive(
const initialParts = normalizePartList(initialPartList); const initialParts = normalizePartList(initialPartList);
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }]; let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
if (adapter) {
const systemMessage = await buildSystemMessage(
config,
sessionId,
permissionMode,
);
adapter.emitMessage(systemMessage);
}
let isFirstTurn = true; let isFirstTurn = true;
while (true) { while (true) {
turnCount++; turnCount++;
@@ -312,8 +223,6 @@ export async function runNonInteractive(
config.getContentGeneratorConfig()?.authType, config.getContentGeneratorConfig()?.authType,
); );
process.stderr.write(`${errorText}\n`); process.stderr.write(`${errorText}\n`);
// Throw error to exit with non-zero code
throw new Error(errorText);
} }
} }
} }
@@ -339,7 +248,7 @@ export async function runNonInteractive(
? options.controlService.permission.getToolCallUpdateCallback() ? options.controlService.permission.getToolCallUpdateCallback()
: undefined; : undefined;
// Create output handler for Task tool (for subagent execution) // Only pass outputUpdateHandler for Task tool
const isTaskTool = finalRequestInfo.name === 'task'; const isTaskTool = finalRequestInfo.name === 'task';
const taskToolProgress = isTaskTool const taskToolProgress = isTaskTool
? createTaskToolProgressHandler( ? createTaskToolProgressHandler(
@@ -349,41 +258,20 @@ export async function runNonInteractive(
) )
: undefined; : undefined;
const taskToolProgressHandler = taskToolProgress?.handler; const taskToolProgressHandler = taskToolProgress?.handler;
// Create output handler for non-Task tools in text mode (for console output)
const nonTaskOutputHandler =
!isTaskTool && !adapter
? (callId: string, outputChunk: ToolResultDisplay) => {
// Print tool output to console in text mode
if (typeof outputChunk === 'string') {
process.stdout.write(outputChunk);
} else if (
outputChunk &&
typeof outputChunk === 'object' &&
'ansiOutput' in outputChunk
) {
// Handle ANSI output - just print as string for now
process.stdout.write(String(outputChunk.ansiOutput));
}
}
: undefined;
// Combine output handlers
const outputUpdateHandler =
taskToolProgressHandler || nonTaskOutputHandler;
const toolResponse = await executeToolCall( const toolResponse = await executeToolCall(
config, config,
finalRequestInfo, finalRequestInfo,
abortController.signal, abortController.signal,
outputUpdateHandler || toolCallUpdateCallback isTaskTool && taskToolProgressHandler
? { ? {
...(outputUpdateHandler && { outputUpdateHandler }), outputUpdateHandler: taskToolProgressHandler,
...(toolCallUpdateCallback && { onToolCallsUpdate: toolCallUpdateCallback,
onToolCallsUpdate: toolCallUpdateCallback,
}),
} }
: undefined, : toolCallUpdateCallback
? {
onToolCallsUpdate: toolCallUpdateCallback,
}
: undefined,
); );
// Note: In JSON mode, subagent messages are automatically added to the main // Note: In JSON mode, subagent messages are automatically added to the main

View File

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

View File

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

View File

@@ -72,7 +72,6 @@ describe('ShellProcessor', () => {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false), getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
getShellExecutionConfig: vi.fn().mockReturnValue({}), getShellExecutionConfig: vi.fn().mockReturnValue({}),
getAllowedTools: vi.fn().mockReturnValue([]),
}; };
context = createMockCommandContext({ context = createMockCommandContext({
@@ -197,35 +196,6 @@ describe('ShellProcessor', () => {
); );
}); });
it('should NOT throw ConfirmationRequiredError when a command matches allowedTools', async () => {
const processor = new ShellProcessor('test-command');
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Do something dangerous: !{rm -rf /}',
);
mockCheckCommandPermissions.mockReturnValue({
allAllowed: false,
disallowedCommands: ['rm -rf /'],
});
(mockConfig.getAllowedTools as Mock).mockReturnValue([
'ShellTool(rm -rf /)',
]);
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }),
});
const result = await processor.process(prompt, context);
expect(mockShellExecute).toHaveBeenCalledWith(
'rm -rf /',
expect.any(String),
expect.any(Function),
expect.any(Object),
false,
expect.any(Object),
);
expect(result).toEqual([{ text: 'Do something dangerous: deleted' }]);
});
it('should NOT throw ConfirmationRequiredError if a command is not allowed but approval mode is YOLO', async () => { it('should NOT throw ConfirmationRequiredError if a command is not allowed but approval mode is YOLO', async () => {
const processor = new ShellProcessor('test-command'); const processor = new ShellProcessor('test-command');
const prompt: PromptPipelineContent = createPromptPipelineContent( const prompt: PromptPipelineContent = createPromptPipelineContent(

View File

@@ -7,13 +7,11 @@
import { import {
ApprovalMode, ApprovalMode,
checkCommandPermissions, checkCommandPermissions,
doesToolInvocationMatch,
escapeShellArg, escapeShellArg,
getShellConfiguration, getShellConfiguration,
ShellExecutionService, ShellExecutionService,
flatMapTextParts, flatMapTextParts,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import type { AnyToolInvocation } from '@qwen-code/qwen-code-core';
import type { CommandContext } from '../../ui/commands/types.js'; import type { CommandContext } from '../../ui/commands/types.js';
import type { IPromptProcessor, PromptPipelineContent } from './types.js'; import type { IPromptProcessor, PromptPipelineContent } from './types.js';
@@ -126,15 +124,6 @@ export class ShellProcessor implements IPromptProcessor {
// Security check on the final, escaped command string. // Security check on the final, escaped command string.
const { allAllowed, disallowedCommands, blockReason, isHardDenial } = const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
checkCommandPermissions(command, config, sessionShellAllowlist); checkCommandPermissions(command, config, sessionShellAllowlist);
const allowedTools = config.getAllowedTools() || [];
const invocation = {
params: { command },
} as AnyToolInvocation;
const isAllowedBySettings = doesToolInvocationMatch(
'run_shell_command',
invocation,
allowedTools,
);
if (!allAllowed) { if (!allAllowed) {
if (isHardDenial) { if (isHardDenial) {
@@ -143,17 +132,10 @@ export class ShellProcessor implements IPromptProcessor {
); );
} }
// If the command is allowed by settings, skip confirmation.
if (isAllowedBySettings) {
continue;
}
// If not a hard denial, respect YOLO mode and auto-approve. // If not a hard denial, respect YOLO mode and auto-approve.
if (config.getApprovalMode() === ApprovalMode.YOLO) { if (config.getApprovalMode() !== ApprovalMode.YOLO) {
continue; disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
} }
disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
} }
} }

View File

@@ -6,12 +6,10 @@
import { render } from 'ink-testing-library'; import { render } from 'ink-testing-library';
import type React from 'react'; import type React from 'react';
import type { Config } from '@qwen-code/qwen-code-core';
import { LoadedSettings } from '../config/settings.js'; import { LoadedSettings } from '../config/settings.js';
import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { SettingsContext } from '../ui/contexts/SettingsContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js';
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
import { ConfigContext } from '../ui/contexts/ConfigContext.js';
const mockSettings = new LoadedSettings( const mockSettings = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} }, { path: '', settings: {}, originalSettings: {} },
@@ -24,24 +22,14 @@ const mockSettings = new LoadedSettings(
export const renderWithProviders = ( export const renderWithProviders = (
component: React.ReactElement, component: React.ReactElement,
{ { shellFocus = true, settings = mockSettings } = {},
shellFocus = true,
settings = mockSettings,
config = undefined,
}: {
shellFocus?: boolean;
settings?: LoadedSettings;
config?: Config;
} = {},
): ReturnType<typeof render> => ): ReturnType<typeof render> =>
render( render(
<SettingsContext.Provider value={settings}> <SettingsContext.Provider value={settings}>
<ConfigContext.Provider value={config}> <ShellFocusContext.Provider value={shellFocus}>
<ShellFocusContext.Provider value={shellFocus}> <KeypressProvider kittyProtocolEnabled={true}>
<KeypressProvider kittyProtocolEnabled={true}> {component}
{component} </KeypressProvider>
</KeypressProvider> </ShellFocusContext.Provider>
</ShellFocusContext.Provider>
</ConfigContext.Provider>
</SettingsContext.Provider>, </SettingsContext.Provider>,
); );

View File

@@ -32,6 +32,7 @@ import {
type Config, type Config,
type IdeInfo, type IdeInfo,
type IdeContext, type IdeContext,
DEFAULT_GEMINI_FLASH_MODEL,
IdeClient, IdeClient,
ideContextStore, ideContextStore,
getErrorMessage, getErrorMessage,
@@ -179,10 +180,15 @@ export const AppContainer = (props: AppContainerProps) => {
[], [],
); );
// Helper to determine the current model (polled, since Config has no model-change event). // Helper to determine the effective model, considering the fallback state.
const getCurrentModel = useCallback(() => config.getModel(), [config]); const getEffectiveModel = useCallback(() => {
if (config.isInFallbackMode()) {
return DEFAULT_GEMINI_FLASH_MODEL;
}
return config.getModel();
}, [config]);
const [currentModel, setCurrentModel] = useState(getCurrentModel()); const [currentModel, setCurrentModel] = useState(getEffectiveModel());
const [isConfigInitialized, setConfigInitialized] = useState(false); const [isConfigInitialized, setConfigInitialized] = useState(false);
@@ -235,12 +241,12 @@ export const AppContainer = (props: AppContainerProps) => {
[historyManager.addItem], [historyManager.addItem],
); );
// Watch for model changes (e.g., user switches model via /model) // Watch for model changes (e.g., from Flash fallback)
useEffect(() => { useEffect(() => {
const checkModelChange = () => { const checkModelChange = () => {
const model = getCurrentModel(); const effectiveModel = getEffectiveModel();
if (model !== currentModel) { if (effectiveModel !== currentModel) {
setCurrentModel(model); setCurrentModel(effectiveModel);
} }
}; };
@@ -248,7 +254,7 @@ export const AppContainer = (props: AppContainerProps) => {
const interval = setInterval(checkModelChange, 1000); // Check every second const interval = setInterval(checkModelChange, 1000); // Check every second
return () => clearInterval(interval); return () => clearInterval(interval);
}, [config, currentModel, getCurrentModel]); }, [config, currentModel, getEffectiveModel]);
const { const {
consoleMessages, consoleMessages,
@@ -370,36 +376,37 @@ export const AppContainer = (props: AppContainerProps) => {
// Check for enforced auth type mismatch // Check for enforced auth type mismatch
useEffect(() => { useEffect(() => {
// Check for initialization error first // Check for initialization error first
const currentAuthType = config.modelsConfig.getCurrentAuthType();
if ( if (
settings.merged.security?.auth?.enforcedType && settings.merged.security?.auth?.enforcedType &&
currentAuthType && settings.merged.security?.auth.selectedType &&
settings.merged.security?.auth.enforcedType !== currentAuthType settings.merged.security?.auth.enforcedType !==
settings.merged.security?.auth.selectedType
) { ) {
onAuthError( onAuthError(
t( t(
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.', 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.',
{ {
enforcedType: String(settings.merged.security?.auth.enforcedType), enforcedType: settings.merged.security?.auth.enforcedType,
currentType: String(currentAuthType), currentType: settings.merged.security?.auth.selectedType,
}, },
), ),
); );
} else if (!settings.merged.security?.auth?.useExternal) { } else if (
// If no authType is selected yet, allow the auth UI flow to prompt the user. settings.merged.security?.auth?.selectedType &&
// Only validate credentials once a concrete authType exists. !settings.merged.security?.auth?.useExternal
if (currentAuthType) { ) {
const error = validateAuthMethod(currentAuthType, config); const error = validateAuthMethod(
if (error) { settings.merged.security.auth.selectedType,
onAuthError(error); );
} if (error) {
onAuthError(error);
} }
} }
}, [ }, [
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.enforcedType, settings.merged.security?.auth?.enforcedType,
settings.merged.security?.auth?.useExternal, settings.merged.security?.auth?.useExternal,
config,
onAuthError, onAuthError,
]); ]);
@@ -918,12 +925,7 @@ export const AppContainer = (props: AppContainerProps) => {
const handleIdePromptComplete = useCallback( const handleIdePromptComplete = useCallback(
(result: IdeIntegrationNudgeResult) => { (result: IdeIntegrationNudgeResult) => {
if (result.userSelection === 'yes') { if (result.userSelection === 'yes') {
// Check whether the extension has been pre-installed handleSlashCommand('/ide install');
if (result.isExtensionPreInstalled) {
handleSlashCommand('/ide enable');
} else {
handleSlashCommand('/ide install');
}
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true); settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
} else if (result.userSelection === 'dismiss') { } else if (result.userSelection === 'dismiss') {
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true); settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);

View File

@@ -38,7 +38,6 @@ export function IdeIntegrationNudge({
); );
const { displayName: ideName } = ide; const { displayName: ideName } = ide;
const isInSandbox = !!process.env['SANDBOX'];
// Assume extension is already installed if the env variables are set. // Assume extension is already installed if the env variables are set.
const isExtensionPreInstalled = const isExtensionPreInstalled =
!!process.env['QWEN_CODE_IDE_SERVER_PORT'] && !!process.env['QWEN_CODE_IDE_SERVER_PORT'] &&
@@ -71,15 +70,13 @@ export function IdeIntegrationNudge({
}, },
]; ];
const installText = isInSandbox const installText = isExtensionPreInstalled
? `Note: In sandbox environments, IDE integration requires manual setup on the host system. If you select Yes, you'll receive instructions on how to set this up.` ? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${
: isExtensionPreInstalled ideName ?? 'your editor'
? `If you select Yes, the CLI will connect to your ${ }.`
ideName ?? 'editor' : `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${
} and have access to your open files and display diffs directly.` ideName ?? 'your editor'
: `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${ }.`;
ideName ?? 'your editor'
}.`;
return ( return (
<Box <Box

View File

@@ -6,8 +6,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthDialog } from './AuthDialog.js'; import { AuthDialog } from './AuthDialog.js';
import { LoadedSettings } from '../../config/settings.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { AuthType } from '@qwen-code/qwen-code-core'; import { AuthType } from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../test-utils/render.js'; import { renderWithProviders } from '../../test-utils/render.js';
import { UIStateContext } from '../contexts/UIStateContext.js'; import { UIStateContext } from '../contexts/UIStateContext.js';
@@ -44,24 +43,17 @@ const renderAuthDialog = (
settings: LoadedSettings, settings: LoadedSettings,
uiStateOverrides: Partial<UIState> = {}, uiStateOverrides: Partial<UIState> = {},
uiActionsOverrides: Partial<UIActions> = {}, uiActionsOverrides: Partial<UIActions> = {},
configAuthType: AuthType | undefined = undefined,
configApiKey: string | undefined = undefined,
) => { ) => {
const uiState = createMockUIState(uiStateOverrides); const uiState = createMockUIState(uiStateOverrides);
const uiActions = createMockUIActions(uiActionsOverrides); const uiActions = createMockUIActions(uiActionsOverrides);
const mockConfig = {
getAuthType: vi.fn(() => configAuthType),
getContentGeneratorConfig: vi.fn(() => ({ apiKey: configApiKey })),
} as unknown as Config;
return renderWithProviders( return renderWithProviders(
<UIStateContext.Provider value={uiState}> <UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}> <UIActionsContext.Provider value={uiActions}>
<AuthDialog /> <AuthDialog />
</UIActionsContext.Provider> </UIActionsContext.Provider>
</UIStateContext.Provider>, </UIStateContext.Provider>,
{ settings, config: mockConfig }, { settings },
); );
}; };
@@ -429,7 +421,6 @@ describe('AuthDialog', () => {
settings, settings,
{}, {},
{ handleAuthSelect }, { handleAuthSelect },
undefined, // config.getAuthType() returns undefined
); );
await wait(); await wait();
@@ -484,7 +475,6 @@ describe('AuthDialog', () => {
settings, settings,
{ authError: 'Initial error' }, { authError: 'Initial error' },
{ handleAuthSelect }, { handleAuthSelect },
undefined, // config.getAuthType() returns undefined
); );
await wait(); await wait();
@@ -538,7 +528,6 @@ describe('AuthDialog', () => {
settings, settings,
{}, {},
{ handleAuthSelect }, { handleAuthSelect },
AuthType.USE_OPENAI, // config.getAuthType() returns USE_OPENAI
); );
await wait(); await wait();
@@ -547,7 +536,7 @@ describe('AuthDialog', () => {
await wait(); await wait();
// Should call handleAuthSelect with undefined to exit // Should call handleAuthSelect with undefined to exit
expect(handleAuthSelect).toHaveBeenCalledWith(undefined); expect(handleAuthSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
unmount(); unmount();
}); });
}); });

View File

@@ -8,12 +8,13 @@ import type React from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { AuthType } from '@qwen-code/qwen-code-core'; import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { SettingScope } from '../../config/settings.js';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
function parseDefaultAuthType( function parseDefaultAuthType(
@@ -31,7 +32,7 @@ function parseDefaultAuthType(
export function AuthDialog(): React.JSX.Element { export function AuthDialog(): React.JSX.Element {
const { pendingAuthType, authError } = useUIState(); const { pendingAuthType, authError } = useUIState();
const { handleAuthSelect: onAuthSelect } = useUIActions(); const { handleAuthSelect: onAuthSelect } = useUIActions();
const config = useConfig(); const settings = useSettings();
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null); const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
@@ -57,10 +58,9 @@ export function AuthDialog(): React.JSX.Element {
return item.value === pendingAuthType; return item.value === pendingAuthType;
} }
// Priority 2: config.getAuthType() - the source of truth // Priority 2: settings.merged.security?.auth?.selectedType
const currentAuthType = config.getAuthType(); if (settings.merged.security?.auth?.selectedType) {
if (currentAuthType) { return item.value === settings.merged.security?.auth?.selectedType;
return item.value === currentAuthType;
} }
// Priority 3: QWEN_DEFAULT_AUTH_TYPE env var // Priority 3: QWEN_DEFAULT_AUTH_TYPE env var
@@ -76,7 +76,7 @@ export function AuthDialog(): React.JSX.Element {
}), }),
); );
const hasApiKey = Boolean(config.getContentGeneratorConfig()?.apiKey); const hasApiKey = Boolean(settings.merged.security?.auth?.apiKey);
const currentSelectedAuthType = const currentSelectedAuthType =
selectedIndex !== null selectedIndex !== null
? items[selectedIndex]?.value ? items[selectedIndex]?.value
@@ -84,7 +84,7 @@ export function AuthDialog(): React.JSX.Element {
const handleAuthSelect = async (authMethod: AuthType) => { const handleAuthSelect = async (authMethod: AuthType) => {
setErrorMessage(null); setErrorMessage(null);
await onAuthSelect(authMethod); await onAuthSelect(authMethod, SettingScope.User);
}; };
const handleHighlight = (authMethod: AuthType) => { const handleHighlight = (authMethod: AuthType) => {
@@ -100,7 +100,7 @@ export function AuthDialog(): React.JSX.Element {
if (errorMessage) { if (errorMessage) {
return; return;
} }
if (config.getAuthType() === undefined) { if (settings.merged.security?.auth?.selectedType === undefined) {
// Prevent exiting if no auth method is set // Prevent exiting if no auth method is set
setErrorMessage( setErrorMessage(
t( t(
@@ -109,7 +109,7 @@ export function AuthDialog(): React.JSX.Element {
); );
return; return;
} }
onAuthSelect(undefined); onAuthSelect(undefined, SettingScope.User);
} }
}, },
{ isActive: true }, { isActive: true },

View File

@@ -4,16 +4,16 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core'; import type { Config } from '@qwen-code/qwen-code-core';
import { import {
AuthEvent, AuthEvent,
AuthType, AuthType,
clearCachedCredentialFile,
getErrorMessage, getErrorMessage,
logAuth, logAuth,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import type { LoadedSettings } from '../../config/settings.js'; import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js'; import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
import { useQwenAuth } from '../hooks/useQwenAuth.js'; import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { AuthState, MessageType } from '../types.js'; import { AuthState, MessageType } from '../types.js';
@@ -27,7 +27,8 @@ export const useAuthCommand = (
config: Config, config: Config,
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void, addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
) => { ) => {
const unAuthenticated = config.getAuthType() === undefined; const unAuthenticated =
settings.merged.security?.auth?.selectedType === undefined;
const [authState, setAuthState] = useState<AuthState>( const [authState, setAuthState] = useState<AuthState>(
unAuthenticated ? AuthState.Updating : AuthState.Unauthenticated, unAuthenticated ? AuthState.Updating : AuthState.Unauthenticated,
@@ -80,35 +81,35 @@ export const useAuthCommand = (
); );
const handleAuthSuccess = useCallback( const handleAuthSuccess = useCallback(
async (authType: AuthType, credentials?: OpenAICredentials) => { async (
authType: AuthType,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
try { try {
const authTypeScope = getPersistScopeForModelSelection(settings); settings.setValue(scope, 'security.auth.selectedType', authType);
settings.setValue(
authTypeScope,
'security.auth.selectedType',
authType,
);
// Only update credentials if not switching to QWEN_OAUTH, // Only update credentials if not switching to QWEN_OAUTH,
// so that OpenAI credentials are preserved when switching to QWEN_OAUTH. // so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
if (authType !== AuthType.QWEN_OAUTH && credentials) { if (authType !== AuthType.QWEN_OAUTH && credentials) {
if (credentials?.apiKey != null) { if (credentials?.apiKey != null) {
settings.setValue( settings.setValue(
authTypeScope, scope,
'security.auth.apiKey', 'security.auth.apiKey',
credentials.apiKey, credentials.apiKey,
); );
} }
if (credentials?.baseUrl != null) { if (credentials?.baseUrl != null) {
settings.setValue( settings.setValue(
authTypeScope, scope,
'security.auth.baseUrl', 'security.auth.baseUrl',
credentials.baseUrl, credentials.baseUrl,
); );
} }
if (credentials?.model != null) { if (credentials?.model != null) {
settings.setValue(authTypeScope, 'model.name', credentials.model); settings.setValue(scope, 'model.name', credentials.model);
} }
await clearCachedCredentialFile();
} }
} catch (error) { } catch (error) {
handleAuthFailure(error); handleAuthFailure(error);
@@ -140,10 +141,14 @@ export const useAuthCommand = (
); );
const performAuth = useCallback( const performAuth = useCallback(
async (authType: AuthType, credentials?: OpenAICredentials) => { async (
authType: AuthType,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
try { try {
await config.refreshAuth(authType); await config.refreshAuth(authType);
handleAuthSuccess(authType, credentials); handleAuthSuccess(authType, scope, credentials);
} catch (e) { } catch (e) {
handleAuthFailure(e); handleAuthFailure(e);
} }
@@ -151,51 +156,18 @@ export const useAuthCommand = (
[config, handleAuthSuccess, handleAuthFailure], [config, handleAuthSuccess, handleAuthFailure],
); );
const isProviderManagedModel = useCallback(
(authType: AuthType, modelId: string | undefined) => {
if (!modelId) {
return false;
}
const modelProviders = settings.merged.modelProviders as
| ModelProvidersConfig
| undefined;
if (!modelProviders) {
return false;
}
const providerModels = modelProviders[authType];
if (!Array.isArray(providerModels)) {
return false;
}
return providerModels.some(
(providerModel) => providerModel.id === modelId,
);
},
[settings],
);
const handleAuthSelect = useCallback( const handleAuthSelect = useCallback(
async (authType: AuthType | undefined, credentials?: OpenAICredentials) => { async (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
if (!authType) { if (!authType) {
setIsAuthDialogOpen(false); setIsAuthDialogOpen(false);
setAuthError(null); setAuthError(null);
return; return;
} }
if (
authType === AuthType.USE_OPENAI &&
credentials?.model &&
isProviderManagedModel(authType, credentials.model)
) {
onAuthError(
t(
'Model "{{modelName}}" is managed via settings.modelProviders. Please complete the fields in settings, or use another model id.',
{ modelName: credentials.model },
),
);
return;
}
setPendingAuthType(authType); setPendingAuthType(authType);
setAuthError(null); setAuthError(null);
setIsAuthDialogOpen(false); setIsAuthDialogOpen(false);
@@ -208,14 +180,14 @@ export const useAuthCommand = (
baseUrl: credentials.baseUrl, baseUrl: credentials.baseUrl,
model: credentials.model, model: credentials.model,
}); });
await performAuth(authType, credentials); await performAuth(authType, scope, credentials);
} }
return; return;
} }
await performAuth(authType); await performAuth(authType, scope);
}, },
[config, performAuth, isProviderManagedModel, onAuthError], [config, performAuth],
); );
const openAuthDialog = useCallback(() => { const openAuthDialog = useCallback(() => {

View File

@@ -4,28 +4,31 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect } from 'vitest';
import { approvalModeCommand } from './approvalModeCommand.js'; import { approvalModeCommand } from './approvalModeCommand.js';
import { import {
type CommandContext, type CommandContext,
CommandKind, CommandKind,
type OpenDialogActionReturn, type OpenDialogActionReturn,
type MessageActionReturn,
} from './types.js'; } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { LoadedSettings } from '../../config/settings.js';
describe('approvalModeCommand', () => { describe('approvalModeCommand', () => {
let mockContext: CommandContext; let mockContext: CommandContext;
let mockSetApprovalMode: ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
mockSetApprovalMode = vi.fn();
mockContext = createMockCommandContext({ mockContext = createMockCommandContext({
services: { services: {
config: { config: {
getApprovalMode: () => 'default', getApprovalMode: () => 'default',
setApprovalMode: mockSetApprovalMode, setApprovalMode: () => {},
}, },
settings: {
merged: {},
setValue: () => {},
forScope: () => ({}),
} as unknown as LoadedSettings,
}, },
}); });
}); });
@@ -38,7 +41,7 @@ describe('approvalModeCommand', () => {
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN); expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
}); });
it('should open approval mode dialog when invoked without arguments', async () => { it('should open approval mode dialog when invoked', async () => {
const result = (await approvalModeCommand.action?.( const result = (await approvalModeCommand.action?.(
mockContext, mockContext,
'', '',
@@ -48,123 +51,16 @@ describe('approvalModeCommand', () => {
expect(result.dialog).toBe('approval-mode'); expect(result.dialog).toBe('approval-mode');
}); });
it('should open approval mode dialog when invoked with whitespace only', async () => { it('should open approval mode dialog with arguments (ignored)', async () => {
const result = (await approvalModeCommand.action?.( const result = (await approvalModeCommand.action?.(
mockContext, mockContext,
' ', 'some arguments',
)) as OpenDialogActionReturn; )) as OpenDialogActionReturn;
expect(result.type).toBe('dialog'); expect(result.type).toBe('dialog');
expect(result.dialog).toBe('approval-mode'); expect(result.dialog).toBe('approval-mode');
}); });
describe('direct mode setting (session-only)', () => {
it('should set approval mode to "plan" when argument is "plan"', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'plan',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toContain('plan');
expect(mockSetApprovalMode).toHaveBeenCalledWith('plan');
});
it('should set approval mode to "yolo" when argument is "yolo"', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'yolo',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toContain('yolo');
expect(mockSetApprovalMode).toHaveBeenCalledWith('yolo');
});
it('should set approval mode to "auto-edit" when argument is "auto-edit"', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'auto-edit',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toContain('auto-edit');
expect(mockSetApprovalMode).toHaveBeenCalledWith('auto-edit');
});
it('should set approval mode to "default" when argument is "default"', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'default',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toContain('default');
expect(mockSetApprovalMode).toHaveBeenCalledWith('default');
});
it('should be case-insensitive for mode argument', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'YOLO',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(mockSetApprovalMode).toHaveBeenCalledWith('yolo');
});
it('should handle argument with leading/trailing whitespace', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
' plan ',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(mockSetApprovalMode).toHaveBeenCalledWith('plan');
});
});
describe('invalid mode argument', () => {
it('should return error for invalid mode', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'invalid-mode',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toContain('invalid-mode');
expect(result.content).toContain('plan');
expect(result.content).toContain('yolo');
expect(mockSetApprovalMode).not.toHaveBeenCalled();
});
});
describe('untrusted folder handling', () => {
it('should return error when setApprovalMode throws (e.g., untrusted folder)', async () => {
const errorMessage =
'Cannot enable privileged approval modes in an untrusted folder.';
mockSetApprovalMode.mockImplementation(() => {
throw new Error(errorMessage);
});
const result = (await approvalModeCommand.action?.(
mockContext,
'yolo',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toBe(errorMessage);
});
});
it('should not have subcommands', () => { it('should not have subcommands', () => {
expect(approvalModeCommand.subCommands).toBeUndefined(); expect(approvalModeCommand.subCommands).toBeUndefined();
}); });

View File

@@ -8,25 +8,9 @@ import type {
SlashCommand, SlashCommand,
CommandContext, CommandContext,
OpenDialogActionReturn, OpenDialogActionReturn,
MessageActionReturn,
} from './types.js'; } from './types.js';
import { CommandKind } from './types.js'; import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
import type { ApprovalMode } from '@qwen-code/qwen-code-core';
import { APPROVAL_MODES } from '@qwen-code/qwen-code-core';
/**
* Parses the argument string and returns the corresponding ApprovalMode if valid.
* Returns undefined if the argument is empty or not a valid mode.
*/
function parseApprovalModeArg(arg: string): ApprovalMode | undefined {
const trimmed = arg.trim().toLowerCase();
if (!trimmed) {
return undefined;
}
// Match against valid approval modes (case-insensitive)
return APPROVAL_MODES.find((mode) => mode.toLowerCase() === trimmed);
}
export const approvalModeCommand: SlashCommand = { export const approvalModeCommand: SlashCommand = {
name: 'approval-mode', name: 'approval-mode',
@@ -35,49 +19,10 @@ export const approvalModeCommand: SlashCommand = {
}, },
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
action: async ( action: async (
context: CommandContext, _context: CommandContext,
args: string, _args: string,
): Promise<OpenDialogActionReturn | MessageActionReturn> => { ): Promise<OpenDialogActionReturn> => ({
const mode = parseApprovalModeArg(args); type: 'dialog',
dialog: 'approval-mode',
// If no argument provided, open the dialog }),
if (!args.trim()) {
return {
type: 'dialog',
dialog: 'approval-mode',
};
}
// If invalid argument, return error message with valid options
if (!mode) {
return {
type: 'message',
messageType: 'error',
content: t('Invalid approval mode "{{arg}}". Valid modes: {{modes}}', {
arg: args.trim(),
modes: APPROVAL_MODES.join(', '),
}),
};
}
// Set the mode for current session only (not persisted)
const { config } = context.services;
if (config) {
try {
config.setApprovalMode(mode);
} catch (e) {
return {
type: 'message',
messageType: 'error',
content: (e as Error).message,
};
}
}
return {
type: 'message',
messageType: 'info',
content: t('Approval mode set to "{{mode}}"', { mode }),
};
},
}; };

View File

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

View File

@@ -191,23 +191,11 @@ export const ideCommand = async (): Promise<SlashCommand> => {
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
action: async (context) => { action: async (context) => {
const installer = getIdeInstaller(currentIDE); const installer = getIdeInstaller(currentIDE);
const isSandBox = !!process.env['SANDBOX'];
if (isSandBox) {
context.ui.addItem(
{
type: 'info',
text: `IDE integration needs to be installed on the host. If you have already installed it, you can directly connect the ide`,
},
Date.now(),
);
return;
}
if (!installer) { if (!installer) {
const ideName = ideClient.getDetectedIdeDisplayName();
context.ui.addItem( context.ui.addItem(
{ {
type: 'error', type: 'error',
text: `Automatic installation is not supported for ${ideName}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`, text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
}, },
Date.now(), Date.now(),
); );

View File

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

View File

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

View File

@@ -11,14 +11,9 @@ import type { SlashCommand, type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js'; import type { LoadedSettings } from '../../config/settings.js';
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { import {
getErrorMessage, getErrorMessage,
loadServerHierarchicalMemory, loadServerHierarchicalMemory,
QWEN_DIR,
setGeminiMdFilename,
type FileDiscoveryService, type FileDiscoveryService,
type LoadServerHierarchicalMemoryResponse, type LoadServerHierarchicalMemoryResponse,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
@@ -36,18 +31,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
}; };
}); });
vi.mock('node:fs/promises', () => {
const readFile = vi.fn();
return {
readFile,
default: {
readFile,
},
};
});
const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock; const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock;
const mockReadFile = readFile as unknown as Mock;
describe('memoryCommand', () => { describe('memoryCommand', () => {
let mockContext: CommandContext; let mockContext: CommandContext;
@@ -68,10 +52,6 @@ describe('memoryCommand', () => {
let mockGetGeminiMdFileCount: Mock; let mockGetGeminiMdFileCount: Mock;
beforeEach(() => { beforeEach(() => {
setGeminiMdFilename('QWEN.md');
mockReadFile.mockReset();
vi.restoreAllMocks();
showCommand = getSubCommand('show'); showCommand = getSubCommand('show');
mockGetUserMemory = vi.fn(); mockGetUserMemory = vi.fn();
@@ -122,52 +102,6 @@ describe('memoryCommand', () => {
expect.any(Number), expect.any(Number),
); );
}); });
it('should show project memory from the configured context file', async () => {
const projectCommand = showCommand.subCommands?.find(
(cmd) => cmd.name === '--project',
);
if (!projectCommand?.action) throw new Error('Command has no action');
setGeminiMdFilename('AGENTS.md');
vi.spyOn(process, 'cwd').mockReturnValue('/test/project');
mockReadFile.mockResolvedValue('project memory');
await projectCommand.action(mockContext, '');
const expectedProjectPath = path.join('/test/project', 'AGENTS.md');
expect(mockReadFile).toHaveBeenCalledWith(expectedProjectPath, 'utf-8');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: expect.stringContaining(expectedProjectPath),
},
expect.any(Number),
);
});
it('should show global memory from the configured context file', async () => {
const globalCommand = showCommand.subCommands?.find(
(cmd) => cmd.name === '--global',
);
if (!globalCommand?.action) throw new Error('Command has no action');
setGeminiMdFilename('AGENTS.md');
vi.spyOn(os, 'homedir').mockReturnValue('/home/user');
mockReadFile.mockResolvedValue('global memory');
await globalCommand.action(mockContext, '');
const expectedGlobalPath = path.join('/home/user', QWEN_DIR, 'AGENTS.md');
expect(mockReadFile).toHaveBeenCalledWith(expectedGlobalPath, 'utf-8');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: expect.stringContaining('Global memory content'),
},
expect.any(Number),
);
});
}); });
describe('/memory add', () => { describe('/memory add', () => {

View File

@@ -6,13 +6,12 @@
import { import {
getErrorMessage, getErrorMessage,
getCurrentGeminiMdFilename,
loadServerHierarchicalMemory, loadServerHierarchicalMemory,
QWEN_DIR, QWEN_DIR,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'os';
import fs from 'node:fs/promises'; import fs from 'fs/promises';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js'; import { CommandKind } from './types.js';
@@ -57,12 +56,7 @@ export const memoryCommand: SlashCommand = {
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
action: async (context) => { action: async (context) => {
try { try {
const workingDir = const projectMemoryPath = path.join(process.cwd(), 'QWEN.md');
context.services.config?.getWorkingDir?.() ?? process.cwd();
const projectMemoryPath = path.join(
workingDir,
getCurrentGeminiMdFilename(),
);
const memoryContent = await fs.readFile( const memoryContent = await fs.readFile(
projectMemoryPath, projectMemoryPath,
'utf-8', 'utf-8',
@@ -110,7 +104,7 @@ export const memoryCommand: SlashCommand = {
const globalMemoryPath = path.join( const globalMemoryPath = path.join(
os.homedir(), os.homedir(),
QWEN_DIR, QWEN_DIR,
getCurrentGeminiMdFilename(), 'QWEN.md',
); );
const globalMemoryContent = await fs.readFile( const globalMemoryContent = await fs.readFile(
globalMemoryPath, globalMemoryPath,

View File

@@ -13,6 +13,12 @@ import {
type ContentGeneratorConfig, type ContentGeneratorConfig,
type Config, type Config,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import * as availableModelsModule from '../models/availableModels.js';
// Mock the availableModels module
vi.mock('../models/availableModels.js', () => ({
getAvailableModelsForAuthType: vi.fn(),
}));
// Helper function to create a mock config // Helper function to create a mock config
function createMockConfig( function createMockConfig(
@@ -25,6 +31,9 @@ function createMockConfig(
describe('modelCommand', () => { describe('modelCommand', () => {
let mockContext: CommandContext; let mockContext: CommandContext;
const mockGetAvailableModelsForAuthType = vi.mocked(
availableModelsModule.getAvailableModelsForAuthType,
);
beforeEach(() => { beforeEach(() => {
mockContext = createMockCommandContext(); mockContext = createMockCommandContext();
@@ -78,6 +87,10 @@ describe('modelCommand', () => {
}); });
it('should return dialog action for QWEN_OAUTH auth type', async () => { it('should return dialog action for QWEN_OAUTH auth type', async () => {
mockGetAvailableModelsForAuthType.mockReturnValue([
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
]);
const mockConfig = createMockConfig({ const mockConfig = createMockConfig({
model: 'test-model', model: 'test-model',
authType: AuthType.QWEN_OAUTH, authType: AuthType.QWEN_OAUTH,
@@ -92,7 +105,11 @@ describe('modelCommand', () => {
}); });
}); });
it('should return dialog action for USE_OPENAI auth type', async () => { it('should return dialog action for USE_OPENAI auth type when model is available', async () => {
mockGetAvailableModelsForAuthType.mockReturnValue([
{ id: 'gpt-4', label: 'gpt-4' },
]);
const mockConfig = createMockConfig({ const mockConfig = createMockConfig({
model: 'test-model', model: 'test-model',
authType: AuthType.USE_OPENAI, authType: AuthType.USE_OPENAI,
@@ -107,7 +124,28 @@ describe('modelCommand', () => {
}); });
}); });
it('should return dialog action for unsupported auth types', async () => { it('should return error for USE_OPENAI auth type when no model is available', async () => {
mockGetAvailableModelsForAuthType.mockReturnValue([]);
const mockConfig = createMockConfig({
model: 'test-model',
authType: AuthType.USE_OPENAI,
});
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'No models available for the current authentication type (openai).',
});
});
it('should return error for unsupported auth types', async () => {
mockGetAvailableModelsForAuthType.mockReturnValue([]);
const mockConfig = createMockConfig({ const mockConfig = createMockConfig({
model: 'test-model', model: 'test-model',
authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType, authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType,
@@ -117,8 +155,10 @@ describe('modelCommand', () => {
const result = await modelCommand.action!(mockContext, ''); const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({ expect(result).toEqual({
type: 'dialog', type: 'message',
dialog: 'model', messageType: 'error',
content:
'No models available for the current authentication type (UNSUPPORTED_AUTH_TYPE).',
}); });
}); });

View File

@@ -11,6 +11,7 @@ import type {
MessageActionReturn, MessageActionReturn,
} from './types.js'; } from './types.js';
import { CommandKind } from './types.js'; import { CommandKind } from './types.js';
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
export const modelCommand: SlashCommand = { export const modelCommand: SlashCommand = {
@@ -29,7 +30,7 @@ export const modelCommand: SlashCommand = {
return { return {
type: 'message', type: 'message',
messageType: 'error', messageType: 'error',
content: t('Configuration not available.'), content: 'Configuration not available.',
}; };
} }
@@ -51,6 +52,22 @@ export const modelCommand: SlashCommand = {
}; };
} }
const availableModels = getAvailableModelsForAuthType(authType);
if (availableModels.length === 0) {
return {
type: 'message',
messageType: 'error',
content: t(
'No models available for the current authentication type ({{authType}}).',
{
authType,
},
),
};
}
// Trigger model selection dialog
return { return {
type: 'dialog', type: 'dialog',
dialog: 'model', dialog: 'model',

View File

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

View File

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

View File

@@ -54,7 +54,7 @@ export function ApprovalModeDialog({
}: ApprovalModeDialogProps): React.JSX.Element { }: ApprovalModeDialogProps): React.JSX.Element {
// Start with User scope by default // Start with User scope by default
const [selectedScope, setSelectedScope] = useState<SettingScope>( const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.Workspace, SettingScope.User,
); );
// Track the currently highlighted approval mode // Track the currently highlighted approval mode

View File

@@ -25,6 +25,7 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js'; import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js'; import { useSettings } from '../contexts/SettingsContext.js';
import { SettingScope } from '../../config/settings.js';
import { AuthState } from '../types.js'; import { AuthState } from '../types.js';
import { AuthType } from '@qwen-code/qwen-code-core'; import { AuthType } from '@qwen-code/qwen-code-core';
import process from 'node:process'; import process from 'node:process';
@@ -201,7 +202,7 @@ export const DialogManager = ({
return ( return (
<OpenAIKeyPrompt <OpenAIKeyPrompt
onSubmit={(apiKey, baseUrl, model) => { onSubmit={(apiKey, baseUrl, model) => {
uiActions.handleAuthSelect(AuthType.USE_OPENAI, { uiActions.handleAuthSelect(AuthType.USE_OPENAI, SettingScope.User, {
apiKey, apiKey,
baseUrl, baseUrl,
model, model,

View File

@@ -10,11 +10,7 @@ import { ModelDialog } from './ModelDialog.js';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js'; import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import type { Config } from '@qwen-code/qwen-code-core'; import type { Config } from '@qwen-code/qwen-code-core';
import { AuthType } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import { import {
AVAILABLE_MODELS_QWEN, AVAILABLE_MODELS_QWEN,
MAINLINE_CODER, MAINLINE_CODER,
@@ -40,29 +36,18 @@ const renderComponent = (
}; };
const combinedProps = { ...defaultProps, ...props }; const combinedProps = { ...defaultProps, ...props };
const mockSettings = {
isTrusted: true,
user: { settings: {} },
workspace: { settings: {} },
setValue: vi.fn(),
} as unknown as LoadedSettings;
const mockConfig = contextValue const mockConfig = contextValue
? ({ ? ({
// --- Functions used by ModelDialog --- // --- Functions used by ModelDialog ---
getModel: vi.fn(() => MAINLINE_CODER), getModel: vi.fn(() => MAINLINE_CODER),
setModel: vi.fn().mockResolvedValue(undefined), setModel: vi.fn(),
switchModel: vi.fn().mockResolvedValue(undefined),
getAuthType: vi.fn(() => 'qwen-oauth'), getAuthType: vi.fn(() => 'qwen-oauth'),
// --- Functions used by ClearcutLogger --- // --- Functions used by ClearcutLogger ---
getUsageStatisticsEnabled: vi.fn(() => true), getUsageStatisticsEnabled: vi.fn(() => true),
getSessionId: vi.fn(() => 'mock-session-id'), getSessionId: vi.fn(() => 'mock-session-id'),
getDebugMode: vi.fn(() => false), getDebugMode: vi.fn(() => false),
getContentGeneratorConfig: vi.fn(() => ({ getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })),
authType: AuthType.QWEN_OAUTH,
model: MAINLINE_CODER,
})),
getUseSmartEdit: vi.fn(() => false), getUseSmartEdit: vi.fn(() => false),
getUseModelRouter: vi.fn(() => false), getUseModelRouter: vi.fn(() => false),
getProxy: vi.fn(() => undefined), getProxy: vi.fn(() => undefined),
@@ -73,27 +58,21 @@ const renderComponent = (
: undefined; : undefined;
const renderResult = render( const renderResult = render(
<SettingsContext.Provider value={mockSettings}> <ConfigContext.Provider value={mockConfig}>
<ConfigContext.Provider value={mockConfig}> <ModelDialog {...combinedProps} />
<ModelDialog {...combinedProps} /> </ConfigContext.Provider>,
</ConfigContext.Provider>
</SettingsContext.Provider>,
); );
return { return {
...renderResult, ...renderResult,
props: combinedProps, props: combinedProps,
mockConfig, mockConfig,
mockSettings,
}; };
}; };
describe('<ModelDialog />', () => { describe('<ModelDialog />', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Ensure env-based fallback models don't leak into this suite from the developer environment.
delete process.env['OPENAI_MODEL'];
delete process.env['ANTHROPIC_MODEL'];
}); });
afterEach(() => { afterEach(() => {
@@ -112,12 +91,8 @@ describe('<ModelDialog />', () => {
const props = mockedSelect.mock.calls[0][0]; const props = mockedSelect.mock.calls[0][0];
expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length); expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length);
expect(props.items[0].value).toBe( expect(props.items[0].value).toBe(MAINLINE_CODER);
`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`, expect(props.items[1].value).toBe(MAINLINE_VLM);
);
expect(props.items[1].value).toBe(
`${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`,
);
expect(props.showNumbers).toBe(true); expect(props.showNumbers).toBe(true);
}); });
@@ -164,93 +139,16 @@ describe('<ModelDialog />', () => {
expect(mockedSelect).toHaveBeenCalledTimes(1); expect(mockedSelect).toHaveBeenCalledTimes(1);
}); });
it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => { it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => {
const { props, mockConfig, mockSettings } = renderComponent({}, {}); // Pass empty object for contextValue const { props, mockConfig } = renderComponent({}, {}); // Pass empty object for contextValue
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
expect(childOnSelect).toBeDefined(); expect(childOnSelect).toBeDefined();
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`); childOnSelect(MAINLINE_CODER);
expect(mockConfig?.switchModel).toHaveBeenCalledWith( // Assert against the default mock provided by renderComponent
AuthType.QWEN_OAUTH, expect(mockConfig?.setModel).toHaveBeenCalledWith(MAINLINE_CODER);
MAINLINE_CODER,
undefined,
{
reason: 'user_manual',
context: 'Model switched via /model dialog',
},
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'model.name',
MAINLINE_CODER,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.QWEN_OAUTH,
);
expect(props.onClose).toHaveBeenCalledTimes(1);
});
it('calls config.switchModel and persists authType+model when selecting a different authType', async () => {
const switchModel = vi.fn().mockResolvedValue(undefined);
const getAuthType = vi.fn(() => AuthType.USE_OPENAI);
const getAvailableModelsForAuthType = vi.fn((t: AuthType) => {
if (t === AuthType.USE_OPENAI) {
return [{ id: 'gpt-4', label: 'GPT-4', authType: t }];
}
if (t === AuthType.QWEN_OAUTH) {
return AVAILABLE_MODELS_QWEN.map((m) => ({
id: m.id,
label: m.label,
authType: AuthType.QWEN_OAUTH,
}));
}
return [];
});
const mockConfigWithSwitchAuthType = {
getAuthType,
getModel: vi.fn(() => 'gpt-4'),
getContentGeneratorConfig: vi.fn(() => ({
authType: AuthType.QWEN_OAUTH,
model: MAINLINE_CODER,
})),
// Add switchModel to the mock object (not the type)
switchModel,
getAvailableModelsForAuthType,
};
const { props, mockSettings } = renderComponent(
{},
// Cast to Config to bypass type checking, matching the runtime behavior
mockConfigWithSwitchAuthType as unknown as Partial<Config>,
);
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
expect(switchModel).toHaveBeenCalledWith(
AuthType.QWEN_OAUTH,
MAINLINE_CODER,
{ requireCachedCredentials: true },
{
reason: 'user_manual',
context: 'AuthType+model switched via /model dialog',
},
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'model.name',
MAINLINE_CODER,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.QWEN_OAUTH,
);
expect(props.onClose).toHaveBeenCalledTimes(1); expect(props.onClose).toHaveBeenCalledTimes(1);
}); });
@@ -295,25 +193,17 @@ describe('<ModelDialog />', () => {
it('updates initialIndex when config context changes', () => { it('updates initialIndex when config context changes', () => {
const mockGetModel = vi.fn(() => MAINLINE_CODER); const mockGetModel = vi.fn(() => MAINLINE_CODER);
const mockGetAuthType = vi.fn(() => 'qwen-oauth'); const mockGetAuthType = vi.fn(() => 'qwen-oauth');
const mockSettings = {
isTrusted: true,
user: { settings: {} },
workspace: { settings: {} },
setValue: vi.fn(),
} as unknown as LoadedSettings;
const { rerender } = render( const { rerender } = render(
<SettingsContext.Provider value={mockSettings}> <ConfigContext.Provider
<ConfigContext.Provider value={
value={ {
{ getModel: mockGetModel,
getModel: mockGetModel, getAuthType: mockGetAuthType,
getAuthType: mockGetAuthType, } as unknown as Config
} as unknown as Config }
} >
> <ModelDialog onClose={vi.fn()} />
<ModelDialog onClose={vi.fn()} /> </ConfigContext.Provider>,
</ConfigContext.Provider>
</SettingsContext.Provider>,
); );
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0); expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
@@ -325,11 +215,9 @@ describe('<ModelDialog />', () => {
} as unknown as Config; } as unknown as Config;
rerender( rerender(
<SettingsContext.Provider value={mockSettings}> <ConfigContext.Provider value={newMockConfig}>
<ConfigContext.Provider value={newMockConfig}> <ModelDialog onClose={vi.fn()} />
<ModelDialog onClose={vi.fn()} /> </ConfigContext.Provider>,
</ConfigContext.Provider>
</SettingsContext.Provider>,
); );
// Should be called at least twice: initial render + re-render after context change // Should be called at least twice: initial render + re-render after context change

View File

@@ -5,210 +5,52 @@
*/ */
import type React from 'react'; import type React from 'react';
import { useCallback, useContext, useMemo, useState } from 'react'; import { useCallback, useContext, useMemo } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { import {
AuthType, AuthType,
ModelSlashCommandEvent, ModelSlashCommandEvent,
logModelSlashCommand, logModelSlashCommand,
type ContentGeneratorConfig,
type ContentGeneratorConfigSource,
type ContentGeneratorConfigSources,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js'; import { ConfigContext } from '../contexts/ConfigContext.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { import {
getAvailableModelsForAuthType, getAvailableModelsForAuthType,
MAINLINE_CODER, MAINLINE_CODER,
} from '../models/availableModels.js'; } from '../models/availableModels.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
interface ModelDialogProps { interface ModelDialogProps {
onClose: () => void; onClose: () => void;
} }
function formatSourceBadge(
source: ContentGeneratorConfigSource | undefined,
): string | undefined {
if (!source) return undefined;
switch (source.kind) {
case 'cli':
return source.detail ? `CLI ${source.detail}` : 'CLI';
case 'env':
return source.envKey ? `ENV ${source.envKey}` : 'ENV';
case 'settings':
return source.settingsPath
? `Settings ${source.settingsPath}`
: 'Settings';
case 'modelProviders': {
const suffix =
source.authType && source.modelId
? `${source.authType}:${source.modelId}`
: source.authType
? `${source.authType}`
: source.modelId
? `${source.modelId}`
: '';
return suffix ? `ModelProviders ${suffix}` : 'ModelProviders';
}
case 'default':
return source.detail ? `Default ${source.detail}` : 'Default';
case 'computed':
return source.detail ? `Computed ${source.detail}` : 'Computed';
case 'programmatic':
return source.detail ? `Programmatic ${source.detail}` : 'Programmatic';
case 'unknown':
default:
return undefined;
}
}
function readSourcesFromConfig(config: unknown): ContentGeneratorConfigSources {
if (!config) {
return {};
}
const maybe = config as {
getContentGeneratorConfigSources?: () => ContentGeneratorConfigSources;
};
return maybe.getContentGeneratorConfigSources?.() ?? {};
}
function maskApiKey(apiKey: string | undefined): string {
if (!apiKey) return '(not set)';
const trimmed = apiKey.trim();
if (trimmed.length === 0) return '(not set)';
if (trimmed.length <= 6) return '***';
const head = trimmed.slice(0, 3);
const tail = trimmed.slice(-4);
return `${head}${tail}`;
}
function persistModelSelection(
settings: ReturnType<typeof useSettings>,
modelId: string,
): void {
const scope = getPersistScopeForModelSelection(settings);
settings.setValue(scope, 'model.name', modelId);
}
function persistAuthTypeSelection(
settings: ReturnType<typeof useSettings>,
authType: AuthType,
): void {
const scope = getPersistScopeForModelSelection(settings);
settings.setValue(scope, 'security.auth.selectedType', authType);
}
function ConfigRow({
label,
value,
badge,
}: {
label: string;
value: React.ReactNode;
badge?: string;
}): React.JSX.Element {
return (
<Box flexDirection="column">
<Box>
<Box minWidth={12} flexShrink={0}>
<Text color={theme.text.secondary}>{label}:</Text>
</Box>
<Box flexGrow={1} flexDirection="row" flexWrap="wrap">
<Text>{value}</Text>
</Box>
</Box>
{badge ? (
<Box>
<Box minWidth={12} flexShrink={0}>
<Text> </Text>
</Box>
<Box flexGrow={1}>
<Text color={theme.text.secondary}>{badge}</Text>
</Box>
</Box>
) : null}
</Box>
);
}
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const config = useContext(ConfigContext); const config = useContext(ConfigContext);
const uiState = useContext(UIStateContext);
const settings = useSettings();
// Local error state for displaying errors within the dialog // Get auth type from config, default to QWEN_OAUTH if not available
const [errorMessage, setErrorMessage] = useState<string | null>(null); const authType = config?.getAuthType() ?? AuthType.QWEN_OAUTH;
const authType = config?.getAuthType(); // Get available models based on auth type
const effectiveConfig = const availableModels = useMemo(
(config?.getContentGeneratorConfig?.() as () => getAvailableModelsForAuthType(authType),
| ContentGeneratorConfig [authType],
| undefined) ?? undefined; );
const sources = readSourcesFromConfig(config);
const availableModelEntries = useMemo(() => {
const allAuthTypes = Object.values(AuthType) as AuthType[];
const modelsByAuthType = allAuthTypes
.map((t) => ({
authType: t,
models: getAvailableModelsForAuthType(t, config ?? undefined),
}))
.filter((x) => x.models.length > 0);
// Fixed order: qwen-oauth first, then others in a stable order
const authTypeOrder: AuthType[] = [
AuthType.QWEN_OAUTH,
AuthType.USE_OPENAI,
AuthType.USE_ANTHROPIC,
AuthType.USE_GEMINI,
AuthType.USE_VERTEX_AI,
];
// Filter to only include authTypes that have models
const availableAuthTypes = new Set(modelsByAuthType.map((x) => x.authType));
const orderedAuthTypes = authTypeOrder.filter((t) =>
availableAuthTypes.has(t),
);
return orderedAuthTypes.flatMap((t) => {
const models =
modelsByAuthType.find((x) => x.authType === t)?.models ?? [];
return models.map((m) => ({ authType: t, model: m }));
});
}, [config]);
const MODEL_OPTIONS = useMemo( const MODEL_OPTIONS = useMemo(
() => () =>
availableModelEntries.map(({ authType: t2, model }) => { availableModels.map((model) => ({
const value = `${t2}::${model.id}`; value: model.id,
const title = ( title: model.label,
<Text> description: model.description || '',
<Text bold color={theme.text.accent}> key: model.id,
[{t2}] })),
</Text> [availableModels],
<Text>{` ${model.label}`}</Text>
</Text>
);
const description = model.description || '';
return {
value,
title,
description,
key: value,
};
}),
[availableModelEntries],
); );
const preferredModelId = config?.getModel() || MAINLINE_CODER; // Determine the Preferred Model (read once when the dialog opens).
const preferredKey = authType ? `${authType}::${preferredModelId}` : ''; const preferredModel = config?.getModel() || MAINLINE_CODER;
useKeypress( useKeypress(
(key) => { (key) => {
@@ -219,83 +61,25 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
{ isActive: true }, { isActive: true },
); );
const initialIndex = useMemo(() => { // Calculate the initial index based on the preferred model.
const index = MODEL_OPTIONS.findIndex( const initialIndex = useMemo(
(option) => option.value === preferredKey, () => MODEL_OPTIONS.findIndex((option) => option.value === preferredModel),
); [MODEL_OPTIONS, preferredModel],
return index === -1 ? 0 : index; );
}, [MODEL_OPTIONS, preferredKey]);
// Handle selection internally (Autonomous Dialog).
const handleSelect = useCallback( const handleSelect = useCallback(
async (selected: string) => { (model: string) => {
// Clear any previous error
setErrorMessage(null);
const sep = '::';
const idx = selected.indexOf(sep);
const selectedAuthType = (
idx >= 0 ? selected.slice(0, idx) : authType
) as AuthType;
const modelId = idx >= 0 ? selected.slice(idx + sep.length) : selected;
if (config) { if (config) {
try { config.setModel(model);
await config.switchModel( const event = new ModelSlashCommandEvent(model);
selectedAuthType,
modelId,
selectedAuthType !== authType &&
selectedAuthType === AuthType.QWEN_OAUTH
? { requireCachedCredentials: true }
: undefined,
{
reason: 'user_manual',
context:
selectedAuthType === authType
? 'Model switched via /model dialog'
: 'AuthType+model switched via /model dialog',
},
);
} catch (e) {
const baseErrorMessage = e instanceof Error ? e.message : String(e);
setErrorMessage(
`Failed to switch model to '${modelId}'.\n\n${baseErrorMessage}`,
);
return;
}
const event = new ModelSlashCommandEvent(modelId);
logModelSlashCommand(config, event); logModelSlashCommand(config, event);
const after = config.getContentGeneratorConfig?.() as
| ContentGeneratorConfig
| undefined;
const effectiveAuthType =
after?.authType ?? selectedAuthType ?? authType;
const effectiveModelId = after?.model ?? modelId;
persistModelSelection(settings, effectiveModelId);
persistAuthTypeSelection(settings, effectiveAuthType);
const baseUrl = after?.baseUrl ?? '(default)';
const maskedKey = maskApiKey(after?.apiKey);
uiState?.historyManager.addItem(
{
type: 'info',
text:
`authType: ${effectiveAuthType}\n` +
`Using model: ${effectiveModelId}\n` +
`Base URL: ${baseUrl}\n` +
`API key: ${maskedKey}`,
},
Date.now(),
);
} }
onClose(); onClose();
}, },
[authType, config, onClose, settings, uiState, setErrorMessage], [config, onClose],
); );
const hasModels = MODEL_OPTIONS.length > 0;
return ( return (
<Box <Box
borderStyle="round" borderStyle="round"
@@ -305,73 +89,14 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
width="100%" width="100%"
> >
<Text bold>{t('Select Model')}</Text> <Text bold>{t('Select Model')}</Text>
<Box marginTop={1}>
<Box marginTop={1} flexDirection="column"> <DescriptiveRadioButtonSelect
<Text color={theme.text.secondary}> items={MODEL_OPTIONS}
{t('Current (effective) configuration')} onSelect={handleSelect}
</Text> initialIndex={initialIndex}
<Box flexDirection="column" marginTop={1}> showNumbers={true}
<ConfigRow label="AuthType" value={authType} /> />
<ConfigRow
label="Model"
value={effectiveConfig?.model ?? config?.getModel?.() ?? ''}
badge={formatSourceBadge(sources['model'])}
/>
{authType !== AuthType.QWEN_OAUTH && (
<>
<ConfigRow
label="Base URL"
value={effectiveConfig?.baseUrl ?? ''}
badge={formatSourceBadge(sources['baseUrl'])}
/>
<ConfigRow
label="API Key"
value={effectiveConfig?.apiKey ? t('(set)') : t('(not set)')}
badge={formatSourceBadge(sources['apiKey'])}
/>
</>
)}
</Box>
</Box> </Box>
{!hasModels ? (
<Box marginTop={1} flexDirection="column">
<Text color={theme.status.warning}>
{t(
'No models available for the current authentication type ({{authType}}).',
{
authType: authType ? String(authType) : t('(none)'),
},
)}
</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t(
'Please configure models in settings.modelProviders or use environment variables.',
)}
</Text>
</Box>
</Box>
) : (
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={MODEL_OPTIONS}
onSelect={handleSelect}
initialIndex={initialIndex}
showNumbers={true}
/>
</Box>
)}
{errorMessage && (
<Box marginTop={1} flexDirection="column" paddingX={1}>
<Text color={theme.status.error} wrap="wrap">
{errorMessage}
</Text>
</Box>
)}
<Box marginTop={1} flexDirection="column"> <Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text> <Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text>
</Box> </Box>

View File

@@ -87,13 +87,7 @@ export async function showResumeSessionPicker(
let selectedId: string | undefined; let selectedId: string | undefined;
const { unmount, waitUntilExit } = render( const { unmount, waitUntilExit } = render(
<KeypressProvider <KeypressProvider kittyProtocolEnabled={false}>
kittyProtocolEnabled={false}
pasteWorkaround={
process.platform === 'win32' ||
parseInt(process.versions.node.split('.')[0], 10) < 20
}
>
<StandalonePickerScreen <StandalonePickerScreen
sessionService={sessionService} sessionService={sessionService}
onSelect={(id) => { onSelect={(id) => {

View File

@@ -11,7 +11,7 @@ import { BaseSelectionList } from './BaseSelectionList.js';
import type { SelectionListItem } from '../../hooks/useSelectionList.js'; import type { SelectionListItem } from '../../hooks/useSelectionList.js';
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> { export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
title: React.ReactNode; title: string;
description: string; description: string;
} }

View File

@@ -30,6 +30,7 @@ export interface UIActions {
) => void; ) => void;
handleAuthSelect: ( handleAuthSelect: (
authType: AuthType | undefined, authType: AuthType | undefined,
scope: SettingScope,
credentials?: OpenAICredentials, credentials?: OpenAICredentials,
) => Promise<void>; ) => Promise<void>;
setAuthState: (state: AuthState) => void; setAuthState: (state: AuthState) => void;

View File

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

View File

@@ -25,6 +25,7 @@ export interface DialogCloseOptions {
isAuthDialogOpen: boolean; isAuthDialogOpen: boolean;
handleAuthSelect: ( handleAuthSelect: (
authType: AuthType | undefined, authType: AuthType | undefined,
scope: SettingScope,
credentials?: OpenAICredentials, credentials?: OpenAICredentials,
) => Promise<void>; ) => Promise<void>;
pendingAuthType: AuthType | undefined; pendingAuthType: AuthType | undefined;

View File

@@ -912,7 +912,7 @@ export const useGeminiStream = (
// Reset quota error flag when starting a new query (not a continuation) // Reset quota error flag when starting a new query (not a continuation)
if (!options?.isContinuation) { if (!options?.isContinuation) {
setModelSwitchedFromQuotaError(false); setModelSwitchedFromQuotaError(false);
// No quota-error / fallback routing mechanism currently; keep state minimal. config.setQuotaErrorOccurred(false);
} }
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();

View File

@@ -1,58 +1,21 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useStdin } from 'ink'; import { useStdin } from 'ink';
import type { EditorType } from '@qwen-code/qwen-code-core'; import type { EditorType } from '@qwen-code/qwen-code-core';
import {
editorCommands,
commandExists as coreCommandExists,
} from '@qwen-code/qwen-code-core';
import { spawnSync } from 'child_process'; import { spawnSync } from 'child_process';
import { useSettings } from '../contexts/SettingsContext.js'; import { useSettings } from '../contexts/SettingsContext.js';
/**
* Cache for command existence checks to avoid repeated execSync calls.
*/
const commandExistsCache = new Map<string, boolean>();
/**
* Check if a command exists in the system with caching.
* Results are cached to improve performance in test environments.
*/
function commandExists(cmd: string): boolean {
if (commandExistsCache.has(cmd)) {
return commandExistsCache.get(cmd)!;
}
const exists = coreCommandExists(cmd);
commandExistsCache.set(cmd, exists);
return exists;
}
/**
* Get the actual executable command for an editor type.
*/
function getExecutableCommand(editorType: EditorType): string {
const commandConfig = editorCommands[editorType];
const commands =
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
const availableCommand = commands.find((cmd) => commandExists(cmd));
if (!availableCommand) {
throw new Error(
`No available editor command found for ${editorType}. ` +
`Tried: ${commands.join(', ')}. ` +
`Please install one of these editors or set a different preferredEditor in settings.`,
);
}
return availableCommand;
}
/** /**
* Determines the editor command to use based on user preferences and platform. * Determines the editor command to use based on user preferences and platform.
*/ */
function getEditorCommand(preferredEditor?: EditorType): string { function getEditorCommand(preferredEditor?: EditorType): string {
if (preferredEditor) { if (preferredEditor) {
return getExecutableCommand(preferredEditor); return preferredEditor;
} }
// Platform-specific defaults with UI preference for macOS // Platform-specific defaults with UI preference for macOS
@@ -100,14 +63,8 @@ export function useLaunchEditor() {
try { try {
setRawMode?.(false); setRawMode?.(false);
// On Windows, .cmd and .bat files need shell: true
const needsShell =
process.platform === 'win32' &&
(editorCommand.endsWith('.cmd') || editorCommand.endsWith('.bat'));
const { status, error } = spawnSync(editorCommand, editorArgs, { const { status, error } = spawnSync(editorCommand, editorArgs, {
stdio: 'inherit', stdio: 'inherit',
shell: needsShell,
}); });
if (error) throw error; if (error) throw error;

View File

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

View File

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

View File

@@ -5,9 +5,139 @@
*/ */
import { useState, useEffect, useRef, useMemo } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import { t, ta } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
export const WITTY_LOADING_PHRASES: string[] = ["I'm Feeling Lucky"]; 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? Thats Ctrl+J.',
];
export const PHRASE_CHANGE_INTERVAL_MS = 15000; export const PHRASE_CHANGE_INTERVAL_MS = 15000;
@@ -22,16 +152,14 @@ export const usePhraseCycler = (
isWaiting: boolean, isWaiting: boolean,
customPhrases?: string[], customPhrases?: string[],
) => { ) => {
// Get phrases from translations if available // Translate all phrases at once if using default phrases
const loadingPhrases = useMemo(() => { const loadingPhrases = useMemo(
if (customPhrases && customPhrases.length > 0) { () =>
return customPhrases; customPhrases && customPhrases.length > 0
} ? customPhrases
const translatedPhrases = ta('WITTY_LOADING_PHRASES'); : WITTY_LOADING_PHRASES.map((phrase) => t(phrase)),
return translatedPhrases.length > 0 [customPhrases],
? translatedPhrases );
: WITTY_LOADING_PHRASES;
}, [customPhrases]);
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState( const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState(
loadingPhrases[0], loadingPhrases[0],

View File

@@ -62,7 +62,7 @@ const mockConfig = {
getAllowedTools: vi.fn(() => []), getAllowedTools: vi.fn(() => []),
getContentGeneratorConfig: () => ({ getContentGeneratorConfig: () => ({
model: 'test-model', model: 'test-model',
authType: 'gemini', authType: 'gemini-api-key',
}), }),
getUseSmartEdit: () => false, getUseSmartEdit: () => false,
getUseModelRouter: () => false, getUseModelRouter: () => false,

View File

@@ -1,205 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
getAvailableModelsForAuthType,
getFilteredQwenModels,
getOpenAIAvailableModelFromEnv,
isVisionModel,
getDefaultVisionModel,
AVAILABLE_MODELS_QWEN,
MAINLINE_VLM,
MAINLINE_CODER,
} from './availableModels.js';
import { AuthType, type Config } from '@qwen-code/qwen-code-core';
describe('availableModels', () => {
describe('AVAILABLE_MODELS_QWEN', () => {
it('should include coder model', () => {
const coderModel = AVAILABLE_MODELS_QWEN.find(
(m) => m.id === MAINLINE_CODER,
);
expect(coderModel).toBeDefined();
expect(coderModel?.isVision).toBeFalsy();
});
it('should include vision model', () => {
const visionModel = AVAILABLE_MODELS_QWEN.find(
(m) => m.id === MAINLINE_VLM,
);
expect(visionModel).toBeDefined();
expect(visionModel?.isVision).toBe(true);
});
});
describe('getFilteredQwenModels', () => {
it('should return all models when vision preview is enabled', () => {
const models = getFilteredQwenModels(true);
expect(models.length).toBe(AVAILABLE_MODELS_QWEN.length);
});
it('should filter out vision models when preview is disabled', () => {
const models = getFilteredQwenModels(false);
expect(models.every((m) => !m.isVision)).toBe(true);
});
});
describe('getOpenAIAvailableModelFromEnv', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('should return null when OPENAI_MODEL is not set', () => {
delete process.env['OPENAI_MODEL'];
expect(getOpenAIAvailableModelFromEnv()).toBeNull();
});
it('should return model from OPENAI_MODEL env var', () => {
process.env['OPENAI_MODEL'] = 'gpt-4-turbo';
const model = getOpenAIAvailableModelFromEnv();
expect(model?.id).toBe('gpt-4-turbo');
expect(model?.label).toBe('gpt-4-turbo');
});
it('should trim whitespace from env var', () => {
process.env['OPENAI_MODEL'] = ' gpt-4 ';
const model = getOpenAIAvailableModelFromEnv();
expect(model?.id).toBe('gpt-4');
});
});
describe('getAvailableModelsForAuthType', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('should return hard-coded qwen models for qwen-oauth', () => {
const models = getAvailableModelsForAuthType(AuthType.QWEN_OAUTH);
expect(models).toEqual(AVAILABLE_MODELS_QWEN);
});
it('should return hard-coded qwen models even when config is provided', () => {
const mockConfig = {
getAvailableModels: vi
.fn()
.mockReturnValue([
{ id: 'custom', label: 'Custom', authType: AuthType.QWEN_OAUTH },
]),
} as unknown as Config;
const models = getAvailableModelsForAuthType(
AuthType.QWEN_OAUTH,
mockConfig,
);
expect(models).toEqual(AVAILABLE_MODELS_QWEN);
});
it('should use config.getAvailableModels for openai authType when available', () => {
const mockModels = [
{
id: 'gpt-4',
label: 'GPT-4',
description: 'Test',
authType: AuthType.USE_OPENAI,
isVision: false,
},
];
const getAvailableModelsForAuthType = vi.fn().mockReturnValue(mockModels);
const mockConfigWithMethod = {
// Prefer the newer API when available.
getAvailableModelsForAuthType,
};
const models = getAvailableModelsForAuthType(
AuthType.USE_OPENAI,
mockConfigWithMethod as unknown as Config,
);
expect(getAvailableModelsForAuthType).toHaveBeenCalled();
expect(models[0].id).toBe('gpt-4');
});
it('should fallback to env var for openai when config returns empty', () => {
process.env['OPENAI_MODEL'] = 'fallback-model';
const mockConfig = {
getAvailableModelsForAuthType: vi.fn().mockReturnValue([]),
} as unknown as Config;
const models = getAvailableModelsForAuthType(
AuthType.USE_OPENAI,
mockConfig,
);
expect(models).toEqual([]);
});
it('should fallback to env var for openai when config throws', () => {
process.env['OPENAI_MODEL'] = 'fallback-model';
const mockConfig = {
getAvailableModelsForAuthType: vi.fn().mockImplementation(() => {
throw new Error('Registry not initialized');
}),
} as unknown as Config;
const models = getAvailableModelsForAuthType(
AuthType.USE_OPENAI,
mockConfig,
);
expect(models).toEqual([]);
});
it('should return env model for openai without config', () => {
process.env['OPENAI_MODEL'] = 'gpt-4-turbo';
const models = getAvailableModelsForAuthType(AuthType.USE_OPENAI);
expect(models[0].id).toBe('gpt-4-turbo');
});
it('should return empty array for openai without config or env', () => {
delete process.env['OPENAI_MODEL'];
const models = getAvailableModelsForAuthType(AuthType.USE_OPENAI);
expect(models).toEqual([]);
});
it('should return empty array for other auth types', () => {
const models = getAvailableModelsForAuthType(AuthType.USE_GEMINI);
expect(models).toEqual([]);
});
});
describe('isVisionModel', () => {
it('should return true for vision model', () => {
expect(isVisionModel(MAINLINE_VLM)).toBe(true);
});
it('should return false for non-vision model', () => {
expect(isVisionModel(MAINLINE_CODER)).toBe(false);
});
it('should return false for unknown model', () => {
expect(isVisionModel('unknown-model')).toBe(false);
});
});
describe('getDefaultVisionModel', () => {
it('should return the vision model ID', () => {
expect(getDefaultVisionModel()).toBe(MAINLINE_VLM);
});
});
});

View File

@@ -4,12 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core';
AuthType,
DEFAULT_QWEN_MODEL,
type Config,
type AvailableModel as CoreAvailableModel,
} from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
export type AvailableModel = { export type AvailableModel = {
@@ -62,78 +57,20 @@ export function getFilteredQwenModels(
*/ */
export function getOpenAIAvailableModelFromEnv(): AvailableModel | null { export function getOpenAIAvailableModelFromEnv(): AvailableModel | null {
const id = process.env['OPENAI_MODEL']?.trim(); const id = process.env['OPENAI_MODEL']?.trim();
return id return id ? { id, label: id } : null;
? {
id,
label: id,
get description() {
return t('Configured via OPENAI_MODEL environment variable');
},
}
: null;
} }
export function getAnthropicAvailableModelFromEnv(): AvailableModel | null { export function getAnthropicAvailableModelFromEnv(): AvailableModel | null {
const id = process.env['ANTHROPIC_MODEL']?.trim(); const id = process.env['ANTHROPIC_MODEL']?.trim();
return id return id ? { id, label: id } : null;
? {
id,
label: id,
get description() {
return t('Configured via ANTHROPIC_MODEL environment variable');
},
}
: null;
} }
/**
* Convert core AvailableModel to CLI AvailableModel format
*/
function convertCoreModelToCliModel(
coreModel: CoreAvailableModel,
): AvailableModel {
return {
id: coreModel.id,
label: coreModel.label,
description: coreModel.description,
isVision: coreModel.isVision ?? coreModel.capabilities?.vision ?? false,
};
}
/**
* Get available models for the given authType.
*
* If a Config object is provided, uses config.getAvailableModelsForAuthType().
* For qwen-oauth, always returns the hard-coded models.
* Falls back to environment variables only when no config is provided.
*/
export function getAvailableModelsForAuthType( export function getAvailableModelsForAuthType(
authType: AuthType, authType: AuthType,
config?: Config,
): AvailableModel[] { ): AvailableModel[] {
// For qwen-oauth, always use hard-coded models, this aligns with the API gateway.
if (authType === AuthType.QWEN_OAUTH) {
return AVAILABLE_MODELS_QWEN;
}
// Use config's model registry when available
if (config) {
try {
const models = config.getAvailableModelsForAuthType(authType);
if (models.length > 0) {
return models.map(convertCoreModelToCliModel);
}
} catch {
// If config throws (e.g., not initialized), return empty array
}
// When a Config object is provided, we intentionally do NOT fall back to env-based
// "raw" models. These may reflect the currently effective config but should not be
// presented as selectable options in /model.
return [];
}
// Fall back to environment variables for specific auth types (no config provided)
switch (authType) { switch (authType) {
case AuthType.QWEN_OAUTH:
return AVAILABLE_MODELS_QWEN;
case AuthType.USE_OPENAI: { case AuthType.USE_OPENAI: {
const openAIModel = getOpenAIAvailableModelFromEnv(); const openAIModel = getOpenAIAvailableModelFromEnv();
return openAIModel ? [openAIModel] : []; return openAIModel ? [openAIModel] : [];
@@ -143,10 +80,13 @@ export function getAvailableModelsForAuthType(
return anthropicModel ? [anthropicModel] : []; return anthropicModel ? [anthropicModel] : [];
} }
default: default:
// For other auth types, return empty array for now
// This can be expanded later according to the design doc
return []; return [];
} }
} }
/**
/** /**
* Hard code the default vision model as a string literal, * Hard code the default vision model as a string literal,
* until our coding model supports multimodal. * until our coding model supports multimodal.

View File

@@ -6,11 +6,7 @@
import { vi, type Mock, type MockInstance } from 'vitest'; import { vi, type Mock, type MockInstance } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core'; import type { Config } from '@qwen-code/qwen-code-core';
import { import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
OutputFormat,
FatalInputError,
ToolErrorType,
} from '@qwen-code/qwen-code-core';
import { import {
getErrorMessage, getErrorMessage,
handleError, handleError,
@@ -69,7 +65,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
describe('errors', () => { describe('errors', () => {
let mockConfig: Config; let mockConfig: Config;
let processExitSpy: MockInstance; let processExitSpy: MockInstance;
let processStderrWriteSpy: MockInstance;
let consoleErrorSpy: MockInstance; let consoleErrorSpy: MockInstance;
beforeEach(() => { beforeEach(() => {
@@ -79,11 +74,6 @@ describe('errors', () => {
// Mock console.error // Mock console.error
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Mock process.stderr.write
processStderrWriteSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
// Mock process.exit to throw instead of actually exiting // Mock process.exit to throw instead of actually exiting
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit called with code: ${code}`); throw new Error(`process.exit called with code: ${code}`);
@@ -94,13 +84,11 @@ describe('errors', () => {
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }), getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
getDebugMode: vi.fn().mockReturnValue(true), getDebugMode: vi.fn().mockReturnValue(true),
isInteractive: vi.fn().mockReturnValue(false),
} as unknown as Config; } as unknown as Config;
}); });
afterEach(() => { afterEach(() => {
consoleErrorSpy.mockRestore(); consoleErrorSpy.mockRestore();
processStderrWriteSpy.mockRestore();
processExitSpy.mockRestore(); processExitSpy.mockRestore();
}); });
@@ -444,87 +432,6 @@ describe('errors', () => {
expect(processExitSpy).not.toHaveBeenCalled(); expect(processExitSpy).not.toHaveBeenCalled();
}); });
}); });
describe('permission denied warnings', () => {
it('should show warning when EXECUTION_DENIED in non-interactive text mode', () => {
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
(mockConfig.isInteractive as Mock).mockReturnValue(false);
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.TEXT);
handleToolError(
toolName,
toolError,
mockConfig,
ToolErrorType.EXECUTION_DENIED,
);
expect(processStderrWriteSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Warning: Tool "test-tool" requires user approval',
),
);
expect(processStderrWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('use the -y flag (YOLO mode)'),
);
expect(processExitSpy).not.toHaveBeenCalled();
});
it('should not show warning when EXECUTION_DENIED in interactive mode', () => {
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
(mockConfig.isInteractive as Mock).mockReturnValue(true);
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.TEXT);
handleToolError(
toolName,
toolError,
mockConfig,
ToolErrorType.EXECUTION_DENIED,
);
expect(processStderrWriteSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
});
it('should not show warning when EXECUTION_DENIED in JSON mode', () => {
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
(mockConfig.isInteractive as Mock).mockReturnValue(false);
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.JSON);
handleToolError(
toolName,
toolError,
mockConfig,
ToolErrorType.EXECUTION_DENIED,
);
expect(processStderrWriteSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
});
it('should not show warning for non-EXECUTION_DENIED errors', () => {
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
(mockConfig.isInteractive as Mock).mockReturnValue(false);
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.TEXT);
handleToolError(
toolName,
toolError,
mockConfig,
ToolErrorType.FILE_NOT_FOUND,
);
expect(processStderrWriteSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
});
});
}); });
describe('handleCancellationError', () => { describe('handleCancellationError', () => {

View File

@@ -11,7 +11,6 @@ import {
parseAndFormatApiError, parseAndFormatApiError,
FatalTurnLimitedError, FatalTurnLimitedError,
FatalCancellationError, FatalCancellationError,
ToolErrorType,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
export function getErrorMessage(error: unknown): string { export function getErrorMessage(error: unknown): string {
@@ -103,24 +102,10 @@ export function handleToolError(
toolName: string, toolName: string,
toolError: Error, toolError: Error,
config: Config, config: Config,
errorCode?: string | number, _errorCode?: string | number,
resultDisplay?: string, resultDisplay?: string,
): void { ): void {
// Check if this is a permission denied error in non-interactive mode // Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere
const isExecutionDenied = errorCode === ToolErrorType.EXECUTION_DENIED;
const isNonInteractive = !config.isInteractive();
const isTextMode = config.getOutputFormat() === OutputFormat.TEXT;
// Show warning for permission denied errors in non-interactive text mode
if (isExecutionDenied && isNonInteractive && isTextMode) {
const warningMessage =
`Warning: Tool "${toolName}" requires user approval but cannot execute in non-interactive mode.\n` +
`To enable automatic tool execution, use the -y flag (YOLO mode):\n` +
`Example: qwen -p 'your prompt' -y\n\n`;
process.stderr.write(warningMessage);
}
// Always log detailed error in debug mode
if (config.getDebugMode()) { if (config.getDebugMode()) {
console.error( console.error(
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`, `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,

View File

@@ -1,133 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
AuthType,
type ContentGeneratorConfig,
type ContentGeneratorConfigSources,
resolveModelConfig,
type ModelConfigSourcesInput,
} from '@qwen-code/qwen-code-core';
import type { Settings } from '../config/settings.js';
export interface CliGenerationConfigInputs {
argv: {
model?: string | undefined;
openaiApiKey?: string | undefined;
openaiBaseUrl?: string | undefined;
openaiLogging?: boolean | undefined;
openaiLoggingDir?: string | undefined;
};
settings: Settings;
selectedAuthType: AuthType | undefined;
/**
* Injectable env for testability. Defaults to process.env at callsites.
*/
env?: Record<string, string | undefined>;
}
export interface ResolvedCliGenerationConfig {
/** The resolved model id (may be empty string if not resolvable at CLI layer) */
model: string;
/** API key for OpenAI-compatible auth */
apiKey: string;
/** Base URL for OpenAI-compatible auth */
baseUrl: string;
/** The full generation config to pass to core Config */
generationConfig: Partial<ContentGeneratorConfig>;
/** Source attribution for each resolved field */
sources: ContentGeneratorConfigSources;
}
export function getAuthTypeFromEnv(): AuthType | undefined {
if (process.env['OPENAI_API_KEY']) {
return AuthType.USE_OPENAI;
}
if (process.env['QWEN_OAUTH']) {
return AuthType.QWEN_OAUTH;
}
if (process.env['GEMINI_API_KEY']) {
return AuthType.USE_GEMINI;
}
if (process.env['GOOGLE_API_KEY']) {
return AuthType.USE_VERTEX_AI;
}
if (process.env['ANTHROPIC_API_KEY']) {
return AuthType.USE_ANTHROPIC;
}
return undefined;
}
/**
* Unified resolver for CLI generation config.
*
* Precedence (for OpenAI auth):
* - model: argv.model > OPENAI_MODEL > QWEN_MODEL > settings.model.name
* - apiKey: argv.openaiApiKey > OPENAI_API_KEY > settings.security.auth.apiKey
* - baseUrl: argv.openaiBaseUrl > OPENAI_BASE_URL > settings.security.auth.baseUrl
*
* For non-OpenAI auth, only argv.model override is respected at CLI layer.
*/
export function resolveCliGenerationConfig(
inputs: CliGenerationConfigInputs,
): ResolvedCliGenerationConfig {
const { argv, settings, selectedAuthType } = inputs;
const env = inputs.env ?? (process.env as Record<string, string | undefined>);
const authType = selectedAuthType;
const configSources: ModelConfigSourcesInput = {
authType,
cli: {
model: argv.model,
apiKey: argv.openaiApiKey,
baseUrl: argv.openaiBaseUrl,
},
settings: {
model: settings.model?.name,
apiKey: settings.security?.auth?.apiKey,
baseUrl: settings.security?.auth?.baseUrl,
generationConfig: settings.model?.generationConfig as
| Partial<ContentGeneratorConfig>
| undefined,
},
env,
};
const resolved = resolveModelConfig(configSources);
// Log warnings if any
for (const warning of resolved.warnings) {
console.warn(`[modelProviderUtils] ${warning}`);
}
// Resolve OpenAI logging config (CLI-specific, not part of core resolver)
const enableOpenAILogging =
(typeof argv.openaiLogging === 'undefined'
? settings.model?.enableOpenAILogging
: argv.openaiLogging) ?? false;
const openAILoggingDir =
argv.openaiLoggingDir || settings.model?.openAILoggingDir;
// Build the full generation config
// Note: we merge the resolved config with logging settings
const generationConfig: Partial<ContentGeneratorConfig> = {
...resolved.config,
enableOpenAILogging,
openAILoggingDir,
};
return {
model: resolved.config.model || '',
apiKey: resolved.config.apiKey || '',
baseUrl: resolved.config.baseUrl || '',
generationConfig,
sources: resolved.sources,
};
}

View File

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

View File

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

View File

@@ -57,7 +57,6 @@ describe('systemInfo', () => {
getModel: vi.fn().mockReturnValue('test-model'), getModel: vi.fn().mockReturnValue('test-model'),
getIdeMode: vi.fn().mockReturnValue(true), getIdeMode: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'), getSessionId: vi.fn().mockReturnValue('test-session-id'),
getAuthType: vi.fn().mockReturnValue('test-auth'),
getContentGeneratorConfig: vi.fn().mockReturnValue({ getContentGeneratorConfig: vi.fn().mockReturnValue({
baseUrl: 'https://api.openai.com', baseUrl: 'https://api.openai.com',
}), }),
@@ -274,9 +273,6 @@ describe('systemInfo', () => {
// Update the mock context to use OpenAI auth // Update the mock context to use OpenAI auth
mockContext.services.settings.merged.security!.auth!.selectedType = mockContext.services.settings.merged.security!.auth!.selectedType =
AuthType.USE_OPENAI; AuthType.USE_OPENAI;
vi.mocked(mockContext.services.config!.getAuthType).mockReturnValue(
AuthType.USE_OPENAI,
);
const extendedInfo = await getExtendedSystemInfo(mockContext); const extendedInfo = await getExtendedSystemInfo(mockContext);

View File

@@ -115,7 +115,8 @@ export async function getSystemInfo(
const sandboxEnv = getSandboxEnv(); const sandboxEnv = getSandboxEnv();
const modelVersion = context.services.config?.getModel() || 'Unknown'; const modelVersion = context.services.config?.getModel() || 'Unknown';
const cliVersion = await getCliVersion(); const cliVersion = await getCliVersion();
const selectedAuthType = context.services.config?.getAuthType() || ''; const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
const ideClient = await getIdeClientName(context); const ideClient = await getIdeClientName(context);
const sessionId = context.services.config?.getSessionId() || 'unknown'; const sessionId = context.services.config?.getSessionId() || 'unknown';

View File

@@ -14,20 +14,6 @@ import * as JsonOutputAdapterModule from './nonInteractive/io/JsonOutputAdapter.
import * as StreamJsonOutputAdapterModule from './nonInteractive/io/StreamJsonOutputAdapter.js'; import * as StreamJsonOutputAdapterModule from './nonInteractive/io/StreamJsonOutputAdapter.js';
import * as cleanupModule from './utils/cleanup.js'; import * as cleanupModule from './utils/cleanup.js';
// Helper to create a mock Config with modelsConfig
function createMockConfig(overrides?: Partial<Config>): Config {
return {
refreshAuth: vi.fn().mockResolvedValue('refreshed'),
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: undefined }),
modelsConfig: {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
...overrides,
} as unknown as Config;
}
describe('validateNonInterActiveAuth', () => { describe('validateNonInterActiveAuth', () => {
let originalEnvGeminiApiKey: string | undefined; let originalEnvGeminiApiKey: string | undefined;
let originalEnvVertexAi: string | undefined; let originalEnvVertexAi: string | undefined;
@@ -121,20 +107,17 @@ describe('validateNonInterActiveAuth', () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it('exits if validateAuthMethod fails for default auth type', async () => { it('exits if no auth type is configured or env vars set', async () => {
// Mock validateAuthMethod to return error (e.g., missing API key) const nonInteractiveConfig = {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue(
'Missing API key for authentication',
);
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
modelsConfig: { getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getModel: vi.fn().mockReturnValue('default-model'), getContentGeneratorConfig: vi
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH), .fn()
}, .mockReturnValue({ authType: undefined }),
}); } as unknown as Config;
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
undefined,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
@@ -144,21 +127,22 @@ describe('validateNonInterActiveAuth', () => {
expect((e as Error).message).toContain('process.exit(1) called'); expect((e as Error).message).toContain('process.exit(1) called');
} }
expect(consoleErrorSpy).toHaveBeenCalledWith( expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Missing API key'), expect.stringContaining('Please set an Auth method'),
); );
expect(processExitSpy).toHaveBeenCalledWith(1); expect(processExitSpy).toHaveBeenCalledWith(1);
}); });
it('uses USE_OPENAI if OPENAI_API_KEY is set', async () => { it('uses USE_OPENAI if OPENAI_API_KEY is set', async () => {
process.env['OPENAI_API_KEY'] = 'fake-openai-key'; process.env['OPENAI_API_KEY'] = 'fake-openai-key';
const nonInteractiveConfig = createMockConfig({ const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
modelsConfig: { getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getModel: vi.fn().mockReturnValue('default-model'), getContentGeneratorConfig: vi
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), .fn()
}, .mockReturnValue({ authType: undefined }),
}); } as unknown as Config;
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
undefined,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
@@ -167,14 +151,15 @@ describe('validateNonInterActiveAuth', () => {
}); });
it('uses configured QWEN_OAUTH if provided', async () => { it('uses configured QWEN_OAUTH if provided', async () => {
const nonInteractiveConfig = createMockConfig({ const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
modelsConfig: { getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getModel: vi.fn().mockReturnValue('default-model'), getContentGeneratorConfig: vi
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH), .fn()
}, .mockReturnValue({ authType: undefined }),
}); } as unknown as Config;
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
AuthType.QWEN_OAUTH,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
@@ -185,11 +170,16 @@ describe('validateNonInterActiveAuth', () => {
it('exits if validateAuthMethod returns error', async () => { it('exits if validateAuthMethod returns error', async () => {
// Mock validateAuthMethod to return error // Mock validateAuthMethod to return error
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
const nonInteractiveConfig = createMockConfig({ const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
}); getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
AuthType.USE_GEMINI,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
@@ -207,13 +197,14 @@ describe('validateNonInterActiveAuth', () => {
const validateAuthMethodSpy = vi const validateAuthMethodSpy = vi
.spyOn(auth, 'validateAuthMethod') .spyOn(auth, 'validateAuthMethod')
.mockReturnValue('Auth error!'); .mockReturnValue('Auth error!');
const nonInteractiveConfig = createMockConfig({ const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
}); } as unknown as Config;
// Even with validation errors, it should not exit // Even with an invalid auth type, it should not exit
// because validation is skipped when useExternalAuth is true. // because validation is skipped.
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
'invalid-auth-type' as AuthType,
true, // useExternalAuth = true true, // useExternalAuth = true
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
@@ -222,8 +213,8 @@ describe('validateNonInterActiveAuth', () => {
expect(validateAuthMethodSpy).not.toHaveBeenCalled(); expect(validateAuthMethodSpy).not.toHaveBeenCalled();
expect(consoleErrorSpy).not.toHaveBeenCalled(); expect(consoleErrorSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled(); expect(processExitSpy).not.toHaveBeenCalled();
// refreshAuth is called with the authType from config.modelsConfig.getCurrentAuthType() // We still expect refreshAuth to be called with the (invalid) type
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.QWEN_OAUTH); expect(refreshAuthMock).toHaveBeenCalledWith('invalid-auth-type');
}); });
it('uses enforcedAuthType if provided', async () => { it('uses enforcedAuthType if provided', async () => {
@@ -231,14 +222,11 @@ describe('validateNonInterActiveAuth', () => {
mockSettings.merged.security!.auth!.selectedType = AuthType.USE_OPENAI; mockSettings.merged.security!.auth!.selectedType = AuthType.USE_OPENAI;
// Set required env var for USE_OPENAI to ensure enforcedAuthType takes precedence // Set required env var for USE_OPENAI to ensure enforcedAuthType takes precedence
process.env['OPENAI_API_KEY'] = 'fake-key'; process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = createMockConfig({ const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
modelsConfig: { } as unknown as Config;
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
});
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
AuthType.USE_OPENAI,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
@@ -249,15 +237,16 @@ describe('validateNonInterActiveAuth', () => {
it('exits if currentAuthType does not match enforcedAuthType', async () => { it('exits if currentAuthType does not match enforcedAuthType', async () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH; mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
process.env['OPENAI_API_KEY'] = 'fake-key'; process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = createMockConfig({ const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
modelsConfig: { getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getModel: vi.fn().mockReturnValue('default-model'), getContentGeneratorConfig: vi
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), .fn()
}, .mockReturnValue({ authType: undefined }),
}); } as unknown as Config;
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
AuthType.USE_OPENAI,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
@@ -290,21 +279,18 @@ describe('validateNonInterActiveAuth', () => {
); );
}); });
it('emits error result and exits when validateAuthMethod fails', async () => { it('emits error result and exits when no auth is configured', async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue( const nonInteractiveConfig = {
'Missing API key for authentication',
);
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON), getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
modelsConfig: { getContentGeneratorConfig: vi
getModel: vi.fn().mockReturnValue('default-model'), .fn()
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH), .mockReturnValue({ authType: undefined }),
}, } as unknown as Config;
});
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
undefined,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
@@ -316,7 +302,9 @@ describe('validateNonInterActiveAuth', () => {
expect(emitResultMock).toHaveBeenCalledWith({ expect(emitResultMock).toHaveBeenCalledWith({
isError: true, isError: true,
errorMessage: expect.stringContaining('Missing API key'), errorMessage: expect.stringContaining(
'Please set an Auth method in your',
),
durationMs: 0, durationMs: 0,
apiDurationMs: 0, apiDurationMs: 0,
numTurns: 0, numTurns: 0,
@@ -331,17 +319,17 @@ describe('validateNonInterActiveAuth', () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH; mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
process.env['OPENAI_API_KEY'] = 'fake-key'; process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = createMockConfig({ const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON), getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
modelsConfig: { getContentGeneratorConfig: vi
getModel: vi.fn().mockReturnValue('default-model'), .fn()
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), .mockReturnValue({ authType: undefined }),
}, } as unknown as Config;
});
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
undefined,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
@@ -366,21 +354,21 @@ describe('validateNonInterActiveAuth', () => {
expect(consoleErrorSpy).not.toHaveBeenCalled(); expect(consoleErrorSpy).not.toHaveBeenCalled();
}); });
it('emits error result and exits when API key validation fails', async () => { it('emits error result and exits when validateAuthMethod fails', async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
process.env['OPENAI_API_KEY'] = 'fake-key'; process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = createMockConfig({ const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON), getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
modelsConfig: { getContentGeneratorConfig: vi
getModel: vi.fn().mockReturnValue('default-model'), .fn()
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), .mockReturnValue({ authType: undefined }),
}, } as unknown as Config;
});
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
AuthType.USE_OPENAI,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
@@ -425,22 +413,19 @@ describe('validateNonInterActiveAuth', () => {
); );
}); });
it('emits error result and exits when validateAuthMethod fails', async () => { it('emits error result and exits when no auth is configured', async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue( const nonInteractiveConfig = {
'Missing API key for authentication',
);
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON), getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false), getIncludePartialMessages: vi.fn().mockReturnValue(false),
modelsConfig: { getContentGeneratorConfig: vi
getModel: vi.fn().mockReturnValue('default-model'), .fn()
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH), .mockReturnValue({ authType: undefined }),
}, } as unknown as Config;
});
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
undefined,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
@@ -452,7 +437,9 @@ describe('validateNonInterActiveAuth', () => {
expect(emitResultMock).toHaveBeenCalledWith({ expect(emitResultMock).toHaveBeenCalledWith({
isError: true, isError: true,
errorMessage: expect.stringContaining('Missing API key'), errorMessage: expect.stringContaining(
'Please set an Auth method in your',
),
durationMs: 0, durationMs: 0,
apiDurationMs: 0, apiDurationMs: 0,
numTurns: 0, numTurns: 0,
@@ -467,18 +454,18 @@ describe('validateNonInterActiveAuth', () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH; mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
process.env['OPENAI_API_KEY'] = 'fake-key'; process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = createMockConfig({ const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON), getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false), getIncludePartialMessages: vi.fn().mockReturnValue(false),
modelsConfig: { getContentGeneratorConfig: vi
getModel: vi.fn().mockReturnValue('default-model'), .fn()
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), .mockReturnValue({ authType: undefined }),
}, } as unknown as Config;
});
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
undefined,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
@@ -503,22 +490,22 @@ describe('validateNonInterActiveAuth', () => {
expect(consoleErrorSpy).not.toHaveBeenCalled(); expect(consoleErrorSpy).not.toHaveBeenCalled();
}); });
it('emits error result and exits when API key validation fails', async () => { it('emits error result and exits when validateAuthMethod fails', async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
process.env['OPENAI_API_KEY'] = 'fake-key'; process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = createMockConfig({ const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON), getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false), getIncludePartialMessages: vi.fn().mockReturnValue(false),
modelsConfig: { getContentGeneratorConfig: vi
getModel: vi.fn().mockReturnValue('default-model'), .fn()
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), .mockReturnValue({ authType: undefined }),
}, } as unknown as Config;
});
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
AuthType.USE_OPENAI,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,

View File

@@ -5,42 +5,69 @@
*/ */
import type { Config } from '@qwen-code/qwen-code-core'; import type { Config } from '@qwen-code/qwen-code-core';
import { OutputFormat } from '@qwen-code/qwen-code-core'; import { AuthType, OutputFormat } from '@qwen-code/qwen-code-core';
import { USER_SETTINGS_PATH } from './config/settings.js';
import { validateAuthMethod } from './config/auth.js'; import { validateAuthMethod } from './config/auth.js';
import { type LoadedSettings } from './config/settings.js'; import { type LoadedSettings } from './config/settings.js';
import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js'; import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';
import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js'; import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';
import { runExitCleanup } from './utils/cleanup.js'; import { runExitCleanup } from './utils/cleanup.js';
function getAuthTypeFromEnv(): AuthType | undefined {
if (process.env['OPENAI_API_KEY']) {
return AuthType.USE_OPENAI;
}
if (process.env['QWEN_OAUTH']) {
return AuthType.QWEN_OAUTH;
}
if (process.env['GEMINI_API_KEY']) {
return AuthType.USE_GEMINI;
}
if (process.env['GOOGLE_API_KEY']) {
return AuthType.USE_VERTEX_AI;
}
if (process.env['ANTHROPIC_API_KEY']) {
return AuthType.USE_ANTHROPIC;
}
return undefined;
}
export async function validateNonInteractiveAuth( export async function validateNonInteractiveAuth(
configuredAuthType: AuthType | undefined,
useExternalAuth: boolean | undefined, useExternalAuth: boolean | undefined,
nonInteractiveConfig: Config, nonInteractiveConfig: Config,
settings: LoadedSettings, settings: LoadedSettings,
): Promise<Config> { ): Promise<Config> {
try { try {
// Get the actual authType from config which has already resolved CLI args, env vars, and settings
const authType = nonInteractiveConfig.modelsConfig.getCurrentAuthType();
if (!authType) {
throw new Error(
'No auth type is selected. Please configure an auth type (e.g. via settings or `--auth-type`) before running in non-interactive mode.',
);
}
const resolvedAuthType: NonNullable<typeof authType> = authType;
const enforcedType = settings.merged.security?.auth?.enforcedType; const enforcedType = settings.merged.security?.auth?.enforcedType;
if (enforcedType && enforcedType !== resolvedAuthType) { if (enforcedType) {
const message = `The configured auth type is ${enforcedType}, but the current auth type is ${resolvedAuthType}. Please re-authenticate with the correct type.`; const currentAuthType = getAuthTypeFromEnv();
if (currentAuthType !== enforcedType) {
const message = `The configured auth type is ${enforcedType}, but the current auth type is ${currentAuthType}. Please re-authenticate with the correct type.`;
throw new Error(message);
}
}
const effectiveAuthType =
enforcedType || configuredAuthType || getAuthTypeFromEnv();
if (!effectiveAuthType) {
const message = `Please set an Auth method in your ${USER_SETTINGS_PATH} or specify one of the following environment variables before running: QWEN_OAUTH, OPENAI_API_KEY`;
throw new Error(message); throw new Error(message);
} }
const authType: AuthType = effectiveAuthType as AuthType;
if (!useExternalAuth) { if (!useExternalAuth) {
const err = validateAuthMethod(resolvedAuthType, nonInteractiveConfig); const err = validateAuthMethod(String(authType));
if (err != null) { if (err != null) {
throw new Error(err); throw new Error(err);
} }
} }
await nonInteractiveConfig.refreshAuth(resolvedAuthType); await nonInteractiveConfig.refreshAuth(authType);
return nonInteractiveConfig; return nonInteractiveConfig;
} catch (error) { } catch (error) {
const outputFormat = nonInteractiveConfig.getOutputFormat(); const outputFormat = nonInteractiveConfig.getOutputFormat();

View File

@@ -8,8 +8,12 @@ export * from './src/index.js';
export { Storage } from './src/config/storage.js'; export { Storage } from './src/config/storage.js';
export { export {
DEFAULT_QWEN_MODEL, DEFAULT_QWEN_MODEL,
DEFAULT_QWEN_FLASH_MODEL,
DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_EMBEDDING_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_EMBEDDING_MODEL,
} from './src/config/models.js'; } from './src/config/models.js';
export { export {
serializeTerminalToObject, serializeTerminalToObject,

View File

@@ -1,6 +1,6 @@
{ {
"name": "@qwen-code/qwen-code-core", "name": "@qwen-code/qwen-code-core",
"version": "0.7.0", "version": "0.6.0",
"description": "Qwen Code Core", "description": "Qwen Code Core",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@@ -15,16 +15,10 @@ import {
DEFAULT_OTLP_ENDPOINT, DEFAULT_OTLP_ENDPOINT,
QwenLogger, QwenLogger,
} from '../telemetry/index.js'; } from '../telemetry/index.js';
import type { import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
ContentGenerator,
ContentGeneratorConfig,
} from '../core/contentGenerator.js';
import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js';
import { import {
AuthType, AuthType,
createContentGenerator,
createContentGeneratorConfig, createContentGeneratorConfig,
resolveContentGeneratorConfigWithSources,
} from '../core/contentGenerator.js'; } from '../core/contentGenerator.js';
import { GeminiClient } from '../core/client.js'; import { GeminiClient } from '../core/client.js';
import { GitService } from '../services/gitService.js'; import { GitService } from '../services/gitService.js';
@@ -214,19 +208,6 @@ describe('Server Config (config.ts)', () => {
vi.spyOn(QwenLogger.prototype, 'logStartSessionEvent').mockImplementation( vi.spyOn(QwenLogger.prototype, 'logStartSessionEvent').mockImplementation(
async () => undefined, async () => undefined,
); );
// Setup default mock for resolveContentGeneratorConfigWithSources
vi.mocked(resolveContentGeneratorConfigWithSources).mockImplementation(
(_config, authType, generationConfig) => ({
config: {
...generationConfig,
authType,
model: generationConfig?.model || MODEL,
apiKey: 'test-key',
} as ContentGeneratorConfig,
sources: {},
}),
);
}); });
describe('initialize', () => { describe('initialize', () => {
@@ -274,28 +255,31 @@ describe('Server Config (config.ts)', () => {
const mockContentConfig = { const mockContentConfig = {
apiKey: 'test-key', apiKey: 'test-key',
model: 'qwen3-coder-plus', model: 'qwen3-coder-plus',
authType,
}; };
vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ vi.mocked(createContentGeneratorConfig).mockReturnValue(
config: mockContentConfig as ContentGeneratorConfig, mockContentConfig,
sources: {}, );
});
// Set fallback mode to true to ensure it gets reset
config.setFallbackMode(true);
expect(config.isInFallbackMode()).toBe(true);
await config.refreshAuth(authType); await config.refreshAuth(authType);
expect(resolveContentGeneratorConfigWithSources).toHaveBeenCalledWith( expect(createContentGeneratorConfig).toHaveBeenCalledWith(
config, config,
authType, authType,
expect.objectContaining({ {
model: MODEL, model: MODEL,
}), baseUrl: undefined,
expect.anything(), },
expect.anything(),
); );
// Verify that contentGeneratorConfig is updated // Verify that contentGeneratorConfig is updated
expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig); expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig);
expect(GeminiClient).toHaveBeenCalledWith(config); expect(GeminiClient).toHaveBeenCalledWith(config);
// Verify that fallback mode is reset
expect(config.isInFallbackMode()).toBe(false);
}); });
it('should not strip thoughts when switching from Vertex to GenAI', async () => { it('should not strip thoughts when switching from Vertex to GenAI', async () => {
@@ -316,129 +300,6 @@ describe('Server Config (config.ts)', () => {
}); });
}); });
describe('model switching optimization (QWEN_OAUTH)', () => {
it('should switch qwen-oauth model in-place without refreshing auth when safe', async () => {
const config = new Config(baseParams);
const mockContentConfig: ContentGeneratorConfig = {
authType: AuthType.QWEN_OAUTH,
model: 'coder-model',
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
baseUrl: DEFAULT_DASHSCOPE_BASE_URL,
timeout: 60000,
maxRetries: 3,
} as ContentGeneratorConfig;
vi.mocked(resolveContentGeneratorConfigWithSources).mockImplementation(
(_config, authType, generationConfig) => ({
config: {
...mockContentConfig,
authType,
model: generationConfig?.model ?? mockContentConfig.model,
} as ContentGeneratorConfig,
sources: {},
}),
);
vi.mocked(createContentGenerator).mockResolvedValue({
generateContent: vi.fn(),
generateContentStream: vi.fn(),
countTokens: vi.fn(),
embedContent: vi.fn(),
} as unknown as ContentGenerator);
// Establish initial qwen-oauth content generator config/content generator.
await config.refreshAuth(AuthType.QWEN_OAUTH);
// Spy after initial refresh to ensure model switch does not re-trigger refreshAuth.
const refreshSpy = vi.spyOn(config, 'refreshAuth');
await config.switchModel(AuthType.QWEN_OAUTH, 'vision-model');
expect(config.getModel()).toBe('vision-model');
expect(refreshSpy).not.toHaveBeenCalled();
// Called once during initial refreshAuth + once during handleModelChange diffing.
expect(
vi.mocked(resolveContentGeneratorConfigWithSources),
).toHaveBeenCalledTimes(2);
expect(vi.mocked(createContentGenerator)).toHaveBeenCalledTimes(1);
});
});
describe('model switching with different credentials (OpenAI)', () => {
it('should refresh auth when switching to model with different envKey', async () => {
// This test verifies the fix for switching between modelProvider models
// with different envKeys (e.g., deepseek-chat with DEEPSEEK_API_KEY)
const configWithModelProviders = new Config({
...baseParams,
authType: AuthType.USE_OPENAI,
modelProvidersConfig: {
openai: [
{
id: 'model-a',
name: 'Model A',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_A',
},
{
id: 'model-b',
name: 'Model B',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_B',
},
],
},
});
const mockContentConfigA: ContentGeneratorConfig = {
authType: AuthType.USE_OPENAI,
model: 'model-a',
apiKey: 'key-a',
baseUrl: 'https://api.example.com/v1',
} as ContentGeneratorConfig;
const mockContentConfigB: ContentGeneratorConfig = {
authType: AuthType.USE_OPENAI,
model: 'model-b',
apiKey: 'key-b',
baseUrl: 'https://api.example.com/v1',
} as ContentGeneratorConfig;
vi.mocked(resolveContentGeneratorConfigWithSources).mockImplementation(
(_config, _authType, generationConfig) => {
const model = generationConfig?.model;
return {
config:
model === 'model-b' ? mockContentConfigB : mockContentConfigA,
sources: {},
};
},
);
vi.mocked(createContentGenerator).mockResolvedValue({
generateContent: vi.fn(),
generateContentStream: vi.fn(),
countTokens: vi.fn(),
embedContent: vi.fn(),
} as unknown as ContentGenerator);
// Initialize with model-a
await configWithModelProviders.refreshAuth(AuthType.USE_OPENAI);
// Spy on refreshAuth to verify it's called when switching to model-b
const refreshSpy = vi.spyOn(configWithModelProviders, 'refreshAuth');
// Switch to model-b (different envKey)
await configWithModelProviders.switchModel(
AuthType.USE_OPENAI,
'model-b',
);
// Should trigger full refresh because envKey changed
expect(refreshSpy).toHaveBeenCalledWith(AuthType.USE_OPENAI);
expect(configWithModelProviders.getModel()).toBe('model-b');
});
});
it('Config constructor should store userMemory correctly', () => { it('Config constructor should store userMemory correctly', () => {
const config = new Config(baseParams); const config = new Config(baseParams);

View File

@@ -16,8 +16,9 @@ import { ProxyAgent, setGlobalDispatcher } from 'undici';
import type { import type {
ContentGenerator, ContentGenerator,
ContentGeneratorConfig, ContentGeneratorConfig,
AuthType,
} from '../core/contentGenerator.js'; } from '../core/contentGenerator.js';
import type { ContentGeneratorConfigSources } from '../core/contentGenerator.js'; import type { FallbackModelHandler } from '../fallback/types.js';
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js'; import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
import type { ShellExecutionConfig } from '../services/shellExecutionService.js'; import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
import type { AnyToolInvocation } from '../tools/tools.js'; import type { AnyToolInvocation } from '../tools/tools.js';
@@ -26,9 +27,8 @@ import type { AnyToolInvocation } from '../tools/tools.js';
import { BaseLlmClient } from '../core/baseLlmClient.js'; import { BaseLlmClient } from '../core/baseLlmClient.js';
import { GeminiClient } from '../core/client.js'; import { GeminiClient } from '../core/client.js';
import { import {
AuthType,
createContentGenerator, createContentGenerator,
resolveContentGeneratorConfigWithSources, createContentGeneratorConfig,
} from '../core/contentGenerator.js'; } from '../core/contentGenerator.js';
import { tokenLimit } from '../core/tokenLimits.js'; import { tokenLimit } from '../core/tokenLimits.js';
@@ -94,7 +94,7 @@ import {
DEFAULT_FILE_FILTERING_OPTIONS, DEFAULT_FILE_FILTERING_OPTIONS,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
} from './constants.js'; } from './constants.js';
import { DEFAULT_QWEN_EMBEDDING_MODEL } from './models.js'; import { DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_MODEL } from './models.js';
import { Storage } from './storage.js'; import { Storage } from './storage.js';
import { ChatRecordingService } from '../services/chatRecordingService.js'; import { ChatRecordingService } from '../services/chatRecordingService.js';
import { import {
@@ -103,12 +103,6 @@ import {
} from '../services/sessionService.js'; } from '../services/sessionService.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import {
ModelsConfig,
type ModelProvidersConfig,
type AvailableModel,
} from '../models/index.js';
// Re-export types // Re-export types
export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig }; export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig };
export { export {
@@ -324,11 +318,6 @@ export interface ConfigParameters {
ideMode?: boolean; ideMode?: boolean;
authType?: AuthType; authType?: AuthType;
generationConfig?: Partial<ContentGeneratorConfig>; generationConfig?: Partial<ContentGeneratorConfig>;
/**
* Optional source map for generationConfig fields (e.g. CLI/env/settings attribution).
* This is used to produce per-field source badges in the UI.
*/
generationConfigSources?: ContentGeneratorConfigSources;
cliVersion?: string; cliVersion?: string;
loadMemoryFromIncludeDirectories?: boolean; loadMemoryFromIncludeDirectories?: boolean;
chatRecording?: boolean; chatRecording?: boolean;
@@ -364,8 +353,6 @@ export interface ConfigParameters {
sdkMode?: boolean; sdkMode?: boolean;
sessionSubagents?: SubagentConfig[]; sessionSubagents?: SubagentConfig[];
channel?: string; channel?: string;
/** Model providers configuration grouped by authType */
modelProvidersConfig?: ModelProvidersConfig;
} }
function normalizeConfigOutputFormat( function normalizeConfigOutputFormat(
@@ -407,12 +394,9 @@ export class Config {
private skillManager!: SkillManager; private skillManager!: SkillManager;
private fileSystemService: FileSystemService; private fileSystemService: FileSystemService;
private contentGeneratorConfig!: ContentGeneratorConfig; private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGeneratorConfigSources: ContentGeneratorConfigSources = {};
private contentGenerator!: ContentGenerator; private contentGenerator!: ContentGenerator;
private _generationConfig: Partial<ContentGeneratorConfig>;
private readonly embeddingModel: string; private readonly embeddingModel: string;
private _modelsConfig!: ModelsConfig;
private readonly modelProvidersConfig?: ModelProvidersConfig;
private readonly sandbox: SandboxConfig | undefined; private readonly sandbox: SandboxConfig | undefined;
private readonly targetDir: string; private readonly targetDir: string;
private workspaceContext: WorkspaceContext; private workspaceContext: WorkspaceContext;
@@ -461,6 +445,7 @@ export class Config {
private readonly folderTrust: boolean; private readonly folderTrust: boolean;
private ideMode: boolean; private ideMode: boolean;
private inFallbackMode = false;
private readonly maxSessionTurns: number; private readonly maxSessionTurns: number;
private readonly sessionTokenLimit: number; private readonly sessionTokenLimit: number;
private readonly listExtensions: boolean; private readonly listExtensions: boolean;
@@ -469,6 +454,8 @@ export class Config {
name: string; name: string;
extensionName: string; extensionName: string;
}>; }>;
fallbackModelHandler?: FallbackModelHandler;
private quotaErrorOccurred: boolean = false;
private readonly summarizeToolOutput: private readonly summarizeToolOutput:
| Record<string, SummarizeToolOutputSettings> | Record<string, SummarizeToolOutputSettings>
| undefined; | undefined;
@@ -583,7 +570,13 @@ export class Config {
this.folderTrustFeature = params.folderTrustFeature ?? false; this.folderTrustFeature = params.folderTrustFeature ?? false;
this.folderTrust = params.folderTrust ?? false; this.folderTrust = params.folderTrust ?? false;
this.ideMode = params.ideMode ?? false; this.ideMode = params.ideMode ?? false;
this.modelProvidersConfig = params.modelProvidersConfig; this._generationConfig = {
model: params.model,
...(params.generationConfig || {}),
baseUrl: params.generationConfig?.baseUrl,
};
this.contentGeneratorConfig = this
._generationConfig as ContentGeneratorConfig;
this.cliVersion = params.cliVersion; this.cliVersion = params.cliVersion;
this.chatRecordingEnabled = params.chatRecording ?? true; this.chatRecordingEnabled = params.chatRecording ?? true;
@@ -626,22 +619,6 @@ export class Config {
setGeminiMdFilename(params.contextFileName); setGeminiMdFilename(params.contextFileName);
} }
// Create ModelsConfig for centralized model management
// Prefer params.authType over generationConfig.authType because:
// - params.authType preserves undefined (user hasn't selected yet)
// - generationConfig.authType may have a default value from resolvers
this._modelsConfig = new ModelsConfig({
initialAuthType: params.authType ?? params.generationConfig?.authType,
modelProvidersConfig: this.modelProvidersConfig,
generationConfig: {
model: params.model,
...(params.generationConfig || {}),
baseUrl: params.generationConfig?.baseUrl,
},
generationConfigSources: params.generationConfigSources,
onModelChange: this.handleModelChange.bind(this),
});
if (this.telemetrySettings.enabled) { if (this.telemetrySettings.enabled) {
initializeTelemetry(this); initializeTelemetry(this);
} }
@@ -692,61 +669,45 @@ export class Config {
return this.contentGenerator; return this.contentGenerator;
} }
/**
* Get the ModelsConfig instance for model-related operations.
* External code (e.g., CLI) can use this to access model configuration.
*/
get modelsConfig(): ModelsConfig {
return this._modelsConfig;
}
/** /**
* Updates the credentials in the generation config. * Updates the credentials in the generation config.
* Exclusive for `OpenAIKeyPrompt` to update credentials via `/auth` * This is needed when credentials are set after Config construction.
* Delegates to ModelsConfig.
*/ */
updateCredentials(credentials: { updateCredentials(credentials: {
apiKey?: string; apiKey?: string;
baseUrl?: string; baseUrl?: string;
model?: string; model?: string;
}): void { }): void {
this._modelsConfig.updateCredentials(credentials); if (credentials.apiKey) {
this._generationConfig.apiKey = credentials.apiKey;
}
if (credentials.baseUrl) {
this._generationConfig.baseUrl = credentials.baseUrl;
}
if (credentials.model) {
this._generationConfig.model = credentials.model;
}
} }
/**
* Refresh authentication and rebuild ContentGenerator.
*/
async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) { async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) {
// Sync modelsConfig state for this auth refresh const newContentGeneratorConfig = createContentGeneratorConfig(
const modelId = this._modelsConfig.getModel();
this._modelsConfig.syncAfterAuthRefresh(authMethod, modelId);
// Check and consume cached credentials flag
const requireCached =
this._modelsConfig.consumeRequireCachedCredentialsFlag();
const { config, sources } = resolveContentGeneratorConfigWithSources(
this, this,
authMethod, authMethod,
this._modelsConfig.getGenerationConfig(), this._generationConfig,
this._modelsConfig.getGenerationConfigSources(),
{
strictModelProvider:
this._modelsConfig.isStrictModelProviderSelection(),
},
); );
const newContentGeneratorConfig = config;
this.contentGenerator = await createContentGenerator( this.contentGenerator = await createContentGenerator(
newContentGeneratorConfig, newContentGeneratorConfig,
this, this,
requireCached ? true : isInitialAuth, isInitialAuth,
); );
// Only assign to instance properties after successful initialization // Only assign to instance properties after successful initialization
this.contentGeneratorConfig = newContentGeneratorConfig; this.contentGeneratorConfig = newContentGeneratorConfig;
this.contentGeneratorConfigSources = sources;
// Initialize BaseLlmClient now that the ContentGenerator is available // Initialize BaseLlmClient now that the ContentGenerator is available
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this); this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
// Reset the session flag since we're explicitly changing auth and using default model
this.inFallbackMode = false;
} }
/** /**
@@ -806,125 +767,31 @@ export class Config {
return this.contentGeneratorConfig; return this.contentGeneratorConfig;
} }
getContentGeneratorConfigSources(): ContentGeneratorConfigSources {
// If contentGeneratorConfigSources is empty (before initializeAuth),
// get sources from ModelsConfig
if (
Object.keys(this.contentGeneratorConfigSources).length === 0 &&
this._modelsConfig
) {
return this._modelsConfig.getGenerationConfigSources();
}
return this.contentGeneratorConfigSources;
}
getModel(): string { getModel(): string {
return this.contentGeneratorConfig?.model || this._modelsConfig.getModel(); return this.contentGeneratorConfig?.model || DEFAULT_QWEN_MODEL;
} }
/**
* Set model programmatically (e.g., VLM auto-switch, fallback).
* Delegates to ModelsConfig.
*/
async setModel( async setModel(
newModel: string, newModel: string,
metadata?: { reason?: string; context?: string }, _metadata?: { reason?: string; context?: string },
): Promise<void> { ): Promise<void> {
await this._modelsConfig.setModel(newModel, metadata);
// Also update contentGeneratorConfig for hot-update compatibility
if (this.contentGeneratorConfig) { if (this.contentGeneratorConfig) {
this.contentGeneratorConfig.model = newModel; this.contentGeneratorConfig.model = newModel;
} }
// TODO: Log _metadata for telemetry if needed
// This _metadata can be used for tracking model switches (reason, context)
} }
/** isInFallbackMode(): boolean {
* Handle model change from ModelsConfig. return this.inFallbackMode;
* This updates the content generator config with the new model settings.
*/
private async handleModelChange(
authType: AuthType,
requiresRefresh: boolean,
): Promise<void> {
if (!this.contentGeneratorConfig) {
return;
}
// Hot update path: only supported for qwen-oauth.
// For other auth types we always refresh to recreate the ContentGenerator.
//
// Rationale:
// - Non-qwen providers may need to re-validate credentials / baseUrl / envKey.
// - ModelsConfig.applyResolvedModelDefaults can clear or change credentials sources.
// - Refresh keeps runtime behavior consistent and centralized.
if (authType === AuthType.QWEN_OAUTH && !requiresRefresh) {
const { config, sources } = resolveContentGeneratorConfigWithSources(
this,
authType,
this._modelsConfig.getGenerationConfig(),
this._modelsConfig.getGenerationConfigSources(),
{
strictModelProvider:
this._modelsConfig.isStrictModelProviderSelection(),
},
);
// Hot-update fields (qwen-oauth models share the same auth + client).
this.contentGeneratorConfig.model = config.model;
this.contentGeneratorConfig.samplingParams = config.samplingParams;
this.contentGeneratorConfig.disableCacheControl =
config.disableCacheControl;
if ('model' in sources) {
this.contentGeneratorConfigSources['model'] = sources['model'];
}
if ('samplingParams' in sources) {
this.contentGeneratorConfigSources['samplingParams'] =
sources['samplingParams'];
}
if ('disableCacheControl' in sources) {
this.contentGeneratorConfigSources['disableCacheControl'] =
sources['disableCacheControl'];
}
return;
}
// Full refresh path
await this.refreshAuth(authType);
} }
/** setFallbackMode(active: boolean): void {
* Get available models for the current authType. this.inFallbackMode = active;
* Delegates to ModelsConfig.
*/
getAvailableModels(): AvailableModel[] {
return this._modelsConfig.getAvailableModels();
} }
/** setFallbackModelHandler(handler: FallbackModelHandler): void {
* Get available models for a specific authType. this.fallbackModelHandler = handler;
* Delegates to ModelsConfig.
*/
getAvailableModelsForAuthType(authType: AuthType): AvailableModel[] {
return this._modelsConfig.getAvailableModelsForAuthType(authType);
}
/**
* Switch authType+model via registry-backed selection.
* This triggers a refresh of the ContentGenerator when required (always on authType changes).
* For qwen-oauth model switches that are hot-update safe, this may update in place.
*
* @param authType - Target authentication type
* @param modelId - Target model ID
* @param options - Additional options like requireCachedCredentials
* @param metadata - Metadata for logging/tracking
*/
async switchModel(
authType: AuthType,
modelId: string,
options?: { requireCachedCredentials?: boolean },
metadata?: { reason?: string; context?: string },
): Promise<void> {
await this._modelsConfig.switchModel(authType, modelId, options, metadata);
} }
getMaxSessionTurns(): number { getMaxSessionTurns(): number {
@@ -935,6 +802,14 @@ export class Config {
return this.sessionTokenLimit; return this.sessionTokenLimit;
} }
setQuotaErrorOccurred(value: boolean): void {
this.quotaErrorOccurred = value;
}
getQuotaErrorOccurred(): boolean {
return this.quotaErrorOccurred;
}
getEmbeddingModel(): string { getEmbeddingModel(): string {
return this.embeddingModel; return this.embeddingModel;
} }
@@ -1276,7 +1151,7 @@ export class Config {
} }
getAuthType(): AuthType | undefined { getAuthType(): AuthType | undefined {
return this.contentGeneratorConfig?.authType; return this.contentGeneratorConfig.authType;
} }
getCliVersion(): string | undefined { getCliVersion(): string | undefined {

View File

@@ -0,0 +1,99 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Config } from './config.js';
import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js';
import fs from 'node:fs';
vi.mock('node:fs');
describe('Flash Model Fallback Configuration', () => {
let config: Config;
beforeEach(() => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => true,
} as fs.Stats);
config = new Config({
targetDir: '/test',
debugMode: false,
cwd: '/test',
model: DEFAULT_GEMINI_MODEL,
});
// Initialize contentGeneratorConfig for testing
(
config as unknown as { contentGeneratorConfig: unknown }
).contentGeneratorConfig = {
model: DEFAULT_GEMINI_MODEL,
authType: 'gemini-api-key',
};
});
// These tests do not actually test fallback. isInFallbackMode() only returns true,
// when setFallbackMode is marked as true. This is to decouple setting a model
// with the fallback mechanism. This will be necessary we introduce more
// intelligent model routing.
describe('setModel', () => {
it('should only mark as switched if contentGeneratorConfig exists', async () => {
// Create config without initializing contentGeneratorConfig
const newConfig = new Config({
targetDir: '/test',
debugMode: false,
cwd: '/test',
model: DEFAULT_GEMINI_MODEL,
});
// Should not crash when contentGeneratorConfig is undefined
await newConfig.setModel(DEFAULT_GEMINI_FLASH_MODEL);
expect(newConfig.isInFallbackMode()).toBe(false);
});
});
describe('getModel', () => {
it('should return contentGeneratorConfig model if available', async () => {
// Simulate initialized content generator config
await config.setModel(DEFAULT_GEMINI_FLASH_MODEL);
expect(config.getModel()).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should fall back to initial model if contentGeneratorConfig is not available', () => {
// Test with fresh config where contentGeneratorConfig might not be set
const newConfig = new Config({
targetDir: '/test',
debugMode: false,
cwd: '/test',
model: 'custom-model',
});
expect(newConfig.getModel()).toBe('custom-model');
});
});
describe('isInFallbackMode', () => {
it('should start as false for new session', () => {
expect(config.isInFallbackMode()).toBe(false);
});
it('should remain false if no model switch occurs', () => {
// Perform other operations that don't involve model switching
expect(config.isInFallbackMode()).toBe(false);
});
it('should persist switched state throughout session', async () => {
await config.setModel(DEFAULT_GEMINI_FLASH_MODEL);
// Setting state for fallback mode as is expected of clients
config.setFallbackMode(true);
expect(config.isInFallbackMode()).toBe(true);
// Should remain true even after getting model
config.getModel();
expect(config.isInFallbackMode()).toBe(true);
});
});
});

View File

@@ -0,0 +1,83 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
getEffectiveModel,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
} from './models.js';
describe('getEffectiveModel', () => {
describe('When NOT in fallback mode', () => {
const isInFallbackMode = false;
it('should return the Pro model when Pro is requested', () => {
const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL);
expect(model).toBe(DEFAULT_GEMINI_MODEL);
});
it('should return the Flash model when Flash is requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_MODEL,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the Lite model when Lite is requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should return a custom model name when requested', () => {
const customModel = 'custom-model-v1';
const model = getEffectiveModel(isInFallbackMode, customModel);
expect(model).toBe(customModel);
});
});
describe('When IN fallback mode', () => {
const isInFallbackMode = true;
it('should downgrade the Pro model to the Flash model', () => {
const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the Flash model when Flash is requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_MODEL,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should HONOR the Lite model when Lite is requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should HONOR any model with "lite" in its name', () => {
const customLiteModel = 'gemini-2.5-custom-lite-vNext';
const model = getEffectiveModel(isInFallbackMode, customLiteModel);
expect(model).toBe(customLiteModel);
});
it('should downgrade any other custom model to the Flash model', () => {
const customModel = 'custom-model-v1-unlisted';
const model = getEffectiveModel(isInFallbackMode, customModel);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
});
});

View File

@@ -7,3 +7,46 @@
export const DEFAULT_QWEN_MODEL = 'coder-model'; export const DEFAULT_QWEN_MODEL = 'coder-model';
export const DEFAULT_QWEN_FLASH_MODEL = 'coder-model'; export const DEFAULT_QWEN_FLASH_MODEL = 'coder-model';
export const DEFAULT_QWEN_EMBEDDING_MODEL = 'text-embedding-v4'; export const DEFAULT_QWEN_EMBEDDING_MODEL = 'text-embedding-v4';
export const DEFAULT_GEMINI_MODEL = 'coder-model';
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
export const DEFAULT_GEMINI_MODEL_AUTO = 'auto';
export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
// Some thinking models do not default to dynamic thinking which is done by a value of -1
export const DEFAULT_THINKING_MODE = -1;
/**
* Determines the effective model to use, applying fallback logic if necessary.
*
* When fallback mode is active, this function enforces the use of the standard
* fallback model. However, it makes an exception for "lite" models (any model
* with "lite" in its name), allowing them to be used to preserve cost savings.
* This ensures that "pro" models are always downgraded, while "lite" model
* requests are honored.
*
* @param isInFallbackMode Whether the application is in fallback mode.
* @param requestedModel The model that was originally requested.
* @returns The effective model name.
*/
export function getEffectiveModel(
isInFallbackMode: boolean,
requestedModel: string,
): string {
// If we are not in fallback mode, simply use the requested model.
if (!isInFallbackMode) {
return requestedModel;
}
// If a "lite" model is requested, honor it. This allows for variations of
// lite models without needing to list them all as constants.
if (requestedModel.includes('lite')) {
return requestedModel;
}
// Default fallback for Gemini CLI.
return DEFAULT_GEMINI_FLASH_MODEL;
}

View File

@@ -32,7 +32,7 @@ import {
type ChatCompressionInfo, type ChatCompressionInfo,
} from './turn.js'; } from './turn.js';
import { getCoreSystemPrompt } from './prompts.js'; import { getCoreSystemPrompt } from './prompts.js';
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js'; import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { setSimulate429 } from '../utils/testUtils.js'; import { setSimulate429 } from '../utils/testUtils.js';
import { tokenLimit } from './tokenLimits.js'; import { tokenLimit } from './tokenLimits.js';
@@ -302,6 +302,8 @@ describe('Gemini Client (client.ts)', () => {
getFileService: vi.fn().mockReturnValue(fileService), getFileService: vi.fn().mockReturnValue(fileService),
getMaxSessionTurns: vi.fn().mockReturnValue(0), getMaxSessionTurns: vi.fn().mockReturnValue(0),
getSessionTokenLimit: vi.fn().mockReturnValue(32000), getSessionTokenLimit: vi.fn().mockReturnValue(32000),
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
setQuotaErrorOccurred: vi.fn(),
getNoBrowser: vi.fn().mockReturnValue(false), getNoBrowser: vi.fn().mockReturnValue(false),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true), getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
@@ -315,6 +317,8 @@ describe('Gemini Client (client.ts)', () => {
getModelRouterService: vi.fn().mockReturnValue({ getModelRouterService: vi.fn().mockReturnValue({
route: vi.fn().mockResolvedValue({ model: 'default-routed-model' }), route: vi.fn().mockResolvedValue({ model: 'default-routed-model' }),
}), }),
isInFallbackMode: vi.fn().mockReturnValue(false),
setFallbackMode: vi.fn(),
getCliVersion: vi.fn().mockReturnValue('1.0.0'), getCliVersion: vi.fn().mockReturnValue('1.0.0'),
getChatCompression: vi.fn().mockReturnValue(undefined), getChatCompression: vi.fn().mockReturnValue(undefined),
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false), getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
@@ -1058,18 +1062,26 @@ describe('Gemini Client (client.ts)', () => {
// Assert // Assert
expect(ideContextStore.get).toHaveBeenCalled(); expect(ideContextStore.get).toHaveBeenCalled();
const expectedContext = `Here is the user's editor context. This is for your information only. const expectedContext = `
Active file: Here is the user's editor context as a JSON object. This is for your information only.
Path: /path/to/active/file.ts \`\`\`json
Cursor: line 5, character 10 ${JSON.stringify(
Selected text: {
activeFile: {
path: '/path/to/active/file.ts',
cursor: {
line: 5,
character: 10,
},
selectedText: 'hello',
},
otherOpenFiles: ['/path/to/recent/file1.ts', '/path/to/recent/file2.ts'],
},
null,
2,
)}
\`\`\` \`\`\`
hello `.trim();
\`\`\`
Other open files:
- /path/to/recent/file1.ts
- /path/to/recent/file2.ts`;
const expectedRequest = [{ text: expectedContext }]; const expectedRequest = [{ text: expectedContext }];
expect(mockChat.addHistory).toHaveBeenCalledWith({ expect(mockChat.addHistory).toHaveBeenCalledWith({
role: 'user', role: 'user',
@@ -1169,14 +1181,25 @@ Other open files:
// Assert // Assert
expect(ideContextStore.get).toHaveBeenCalled(); expect(ideContextStore.get).toHaveBeenCalled();
const expectedContext = `Here is the user's editor context. This is for your information only. const expectedContext = `
Active file: Here is the user's editor context as a JSON object. This is for your information only.
Path: /path/to/active/file.ts \`\`\`json
Cursor: line 5, character 10 ${JSON.stringify(
Selected text: {
activeFile: {
path: '/path/to/active/file.ts',
cursor: {
line: 5,
character: 10,
},
selectedText: 'hello',
},
},
null,
2,
)}
\`\`\` \`\`\`
hello `.trim();
\`\`\``;
const expectedRequest = [{ text: expectedContext }]; const expectedRequest = [{ text: expectedContext }];
expect(mockChat.addHistory).toHaveBeenCalledWith({ expect(mockChat.addHistory).toHaveBeenCalledWith({
role: 'user', role: 'user',
@@ -1235,10 +1258,18 @@ hello
// Assert // Assert
expect(ideContextStore.get).toHaveBeenCalled(); expect(ideContextStore.get).toHaveBeenCalled();
const expectedContext = `Here is the user's editor context. This is for your information only. const expectedContext = `
Other open files: Here is the user's editor context as a JSON object. This is for your information only.
- /path/to/recent/file1.ts \`\`\`json
- /path/to/recent/file2.ts`; ${JSON.stringify(
{
otherOpenFiles: ['/path/to/recent/file1.ts', '/path/to/recent/file2.ts'],
},
null,
2,
)}
\`\`\`
`.trim();
const expectedRequest = [{ text: expectedContext }]; const expectedRequest = [{ text: expectedContext }];
expect(mockChat.addHistory).toHaveBeenCalledWith({ expect(mockChat.addHistory).toHaveBeenCalledWith({
role: 'user', role: 'user',
@@ -1755,9 +1786,11 @@ Other open files:
// Also verify it's the full context, not a delta. // Also verify it's the full context, not a delta.
const call = mockChat.addHistory.mock.calls[0][0]; const call = mockChat.addHistory.mock.calls[0][0];
const contextText = call.parts[0].text; const contextText = call.parts[0].text;
// Verify it contains the active file information in plain text format const contextJson = JSON.parse(
expect(contextText).toContain('Active file:'); contextText.match(/```json\n(.*)\n```/s)![1],
expect(contextText).toContain('Path: /path/to/active/file.ts'); );
expect(contextJson).toHaveProperty('activeFile');
expect(contextJson.activeFile.path).toBe('/path/to/active/file.ts');
}); });
}); });
@@ -1960,7 +1993,7 @@ Other open files:
); );
expect(contextCall).toBeDefined(); expect(contextCall).toBeDefined();
expect(JSON.stringify(contextCall![0])).toContain( expect(JSON.stringify(contextCall![0])).toContain(
"Here is the user's editor context.", "Here is the user's editor context as a JSON object",
); );
// Check that the sent context is the new one (fileB.ts) // Check that the sent context is the new one (fileB.ts)
expect(JSON.stringify(contextCall![0])).toContain('fileB.ts'); expect(JSON.stringify(contextCall![0])).toContain('fileB.ts');
@@ -1996,7 +2029,9 @@ Other open files:
// Assert: Full context for fileA.ts was sent and stored. // Assert: Full context for fileA.ts was sent and stored.
const initialCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0]; const initialCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0];
expect(JSON.stringify(initialCall)).toContain("user's editor context."); expect(JSON.stringify(initialCall)).toContain(
"user's editor context as a JSON object",
);
expect(JSON.stringify(initialCall)).toContain('fileA.ts'); expect(JSON.stringify(initialCall)).toContain('fileA.ts');
// This implicitly tests that `lastSentIdeContext` is now set internally by the client. // This implicitly tests that `lastSentIdeContext` is now set internally by the client.
vi.mocked(mockChat.addHistory!).mockClear(); vi.mocked(mockChat.addHistory!).mockClear();
@@ -2094,9 +2129,9 @@ Other open files:
const finalCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0]; const finalCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0];
expect(JSON.stringify(finalCall)).toContain('summary of changes'); expect(JSON.stringify(finalCall)).toContain('summary of changes');
// The delta should reflect fileA being closed and fileC being opened. // The delta should reflect fileA being closed and fileC being opened.
expect(JSON.stringify(finalCall)).toContain('Files closed'); expect(JSON.stringify(finalCall)).toContain('filesClosed');
expect(JSON.stringify(finalCall)).toContain('fileA.ts'); expect(JSON.stringify(finalCall)).toContain('fileA.ts');
expect(JSON.stringify(finalCall)).toContain('Active file changed'); expect(JSON.stringify(finalCall)).toContain('activeFileChanged');
expect(JSON.stringify(finalCall)).toContain('fileC.ts'); expect(JSON.stringify(finalCall)).toContain('fileC.ts');
}); });
}); });
@@ -2227,12 +2262,12 @@ Other open files:
contents, contents,
generationConfig, generationConfig,
abortSignal, abortSignal,
DEFAULT_QWEN_FLASH_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
); );
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
model: DEFAULT_QWEN_FLASH_MODEL, model: DEFAULT_GEMINI_FLASH_MODEL,
config: expect.objectContaining({ config: expect.objectContaining({
abortSignal, abortSignal,
systemInstruction: getCoreSystemPrompt(''), systemInstruction: getCoreSystemPrompt(''),
@@ -2255,7 +2290,7 @@ Other open files:
contents, contents,
{}, {},
new AbortController().signal, new AbortController().signal,
DEFAULT_QWEN_FLASH_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
); );
expect(mockContentGenerator.generateContent).not.toHaveBeenCalledWith({ expect(mockContentGenerator.generateContent).not.toHaveBeenCalledWith({
@@ -2265,7 +2300,7 @@ Other open files:
}); });
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
{ {
model: DEFAULT_QWEN_FLASH_MODEL, model: DEFAULT_GEMINI_FLASH_MODEL,
config: expect.any(Object), config: expect.any(Object),
contents, contents,
}, },
@@ -2273,7 +2308,28 @@ Other open files:
); );
}); });
// Note: there is currently no "fallback mode" model routing; the model used it('should use the Flash model when fallback mode is active', async () => {
// is always the one explicitly requested by the caller. const contents = [{ role: 'user', parts: [{ text: 'hello' }] }];
const generationConfig = { temperature: 0.5 };
const abortSignal = new AbortController().signal;
const requestedModel = 'gemini-2.5-pro'; // A non-flash model
// Mock config to be in fallback mode
vi.spyOn(client['config'], 'isInFallbackMode').mockReturnValue(true);
await client.generateContent(
contents,
generationConfig,
abortSignal,
requestedModel,
);
expect(mockGenerateContentFn).toHaveBeenCalledWith(
expect.objectContaining({
model: DEFAULT_GEMINI_FLASH_MODEL,
}),
'test-session-id',
);
});
}); });
}); });

View File

@@ -15,6 +15,7 @@ import type {
// Config // Config
import { ApprovalMode, type Config } from '../config/config.js'; import { ApprovalMode, type Config } from '../config/config.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
// Core modules // Core modules
import type { ContentGenerator } from './contentGenerator.js'; import type { ContentGenerator } from './contentGenerator.js';
@@ -218,48 +219,42 @@ export class GeminiClient {
} }
if (forceFullContext || !this.lastSentIdeContext) { if (forceFullContext || !this.lastSentIdeContext) {
// Send full context as plain text // Send full context as JSON
const openFiles = currentIdeContext.workspaceState?.openFiles || []; const openFiles = currentIdeContext.workspaceState?.openFiles || [];
const activeFile = openFiles.find((f) => f.isActive); const activeFile = openFiles.find((f) => f.isActive);
const otherOpenFiles = openFiles const otherOpenFiles = openFiles
.filter((f) => !f.isActive) .filter((f) => !f.isActive)
.map((f) => f.path); .map((f) => f.path);
const contextLines: string[] = []; const contextData: Record<string, unknown> = {};
if (activeFile) { if (activeFile) {
contextLines.push('Active file:'); contextData['activeFile'] = {
contextLines.push(` Path: ${activeFile.path}`); path: activeFile.path,
if (activeFile.cursor) { cursor: activeFile.cursor
contextLines.push( ? {
` Cursor: line ${activeFile.cursor.line}, character ${activeFile.cursor.character}`, line: activeFile.cursor.line,
); character: activeFile.cursor.character,
} }
if (activeFile.selectedText) { : undefined,
contextLines.push(' Selected text:'); selectedText: activeFile.selectedText || undefined,
contextLines.push('```'); };
contextLines.push(activeFile.selectedText);
contextLines.push('```');
}
} }
if (otherOpenFiles.length > 0) { if (otherOpenFiles.length > 0) {
if (contextLines.length > 0) { contextData['otherOpenFiles'] = otherOpenFiles;
contextLines.push('');
}
contextLines.push('Other open files:');
for (const filePath of otherOpenFiles) {
contextLines.push(` - ${filePath}`);
}
} }
if (contextLines.length === 0) { if (Object.keys(contextData).length === 0) {
return { contextParts: [], newIdeContext: currentIdeContext }; return { contextParts: [], newIdeContext: currentIdeContext };
} }
const jsonString = JSON.stringify(contextData, null, 2);
const contextParts = [ const contextParts = [
"Here is the user's editor context. This is for your information only.", "Here is the user's editor context as a JSON object. This is for your information only.",
contextLines.join('\n'), '```json',
jsonString,
'```',
]; ];
if (this.config.getDebugMode()) { if (this.config.getDebugMode()) {
@@ -270,8 +265,9 @@ export class GeminiClient {
newIdeContext: currentIdeContext, newIdeContext: currentIdeContext,
}; };
} else { } else {
// Calculate and send delta as plain text // Calculate and send delta as JSON
const changeLines: string[] = []; const delta: Record<string, unknown> = {};
const changes: Record<string, unknown> = {};
const lastFiles = new Map( const lastFiles = new Map(
(this.lastSentIdeContext.workspaceState?.openFiles || []).map( (this.lastSentIdeContext.workspaceState?.openFiles || []).map(
@@ -292,10 +288,7 @@ export class GeminiClient {
} }
} }
if (openedFiles.length > 0) { if (openedFiles.length > 0) {
changeLines.push('Files opened:'); changes['filesOpened'] = openedFiles;
for (const filePath of openedFiles) {
changeLines.push(` - ${filePath}`);
}
} }
const closedFiles: string[] = []; const closedFiles: string[] = [];
@@ -305,13 +298,7 @@ export class GeminiClient {
} }
} }
if (closedFiles.length > 0) { if (closedFiles.length > 0) {
if (changeLines.length > 0) { changes['filesClosed'] = closedFiles;
changeLines.push('');
}
changeLines.push('Files closed:');
for (const filePath of closedFiles) {
changeLines.push(` - ${filePath}`);
}
} }
const lastActiveFile = ( const lastActiveFile = (
@@ -323,22 +310,16 @@ export class GeminiClient {
if (currentActiveFile) { if (currentActiveFile) {
if (!lastActiveFile || lastActiveFile.path !== currentActiveFile.path) { if (!lastActiveFile || lastActiveFile.path !== currentActiveFile.path) {
if (changeLines.length > 0) { changes['activeFileChanged'] = {
changeLines.push(''); path: currentActiveFile.path,
} cursor: currentActiveFile.cursor
changeLines.push('Active file changed:'); ? {
changeLines.push(` Path: ${currentActiveFile.path}`); line: currentActiveFile.cursor.line,
if (currentActiveFile.cursor) { character: currentActiveFile.cursor.character,
changeLines.push( }
` Cursor: line ${currentActiveFile.cursor.line}, character ${currentActiveFile.cursor.character}`, : undefined,
); selectedText: currentActiveFile.selectedText || undefined,
} };
if (currentActiveFile.selectedText) {
changeLines.push(' Selected text:');
changeLines.push('```');
changeLines.push(currentActiveFile.selectedText);
changeLines.push('```');
}
} else { } else {
const lastCursor = lastActiveFile.cursor; const lastCursor = lastActiveFile.cursor;
const currentCursor = currentActiveFile.cursor; const currentCursor = currentActiveFile.cursor;
@@ -348,50 +329,42 @@ export class GeminiClient {
lastCursor.line !== currentCursor.line || lastCursor.line !== currentCursor.line ||
lastCursor.character !== currentCursor.character) lastCursor.character !== currentCursor.character)
) { ) {
if (changeLines.length > 0) { changes['cursorMoved'] = {
changeLines.push(''); path: currentActiveFile.path,
} cursor: {
changeLines.push('Cursor moved:'); line: currentCursor.line,
changeLines.push(` Path: ${currentActiveFile.path}`); character: currentCursor.character,
changeLines.push( },
` New position: line ${currentCursor.line}, character ${currentCursor.character}`, };
);
} }
const lastSelectedText = lastActiveFile.selectedText || ''; const lastSelectedText = lastActiveFile.selectedText || '';
const currentSelectedText = currentActiveFile.selectedText || ''; const currentSelectedText = currentActiveFile.selectedText || '';
if (lastSelectedText !== currentSelectedText) { if (lastSelectedText !== currentSelectedText) {
if (changeLines.length > 0) { changes['selectionChanged'] = {
changeLines.push(''); path: currentActiveFile.path,
} selectedText: currentSelectedText,
changeLines.push('Selection changed:'); };
changeLines.push(` Path: ${currentActiveFile.path}`);
if (currentSelectedText) {
changeLines.push(' Selected text:');
changeLines.push('```');
changeLines.push(currentSelectedText);
changeLines.push('```');
} else {
changeLines.push(' Selected text: (none)');
}
} }
} }
} else if (lastActiveFile) { } else if (lastActiveFile) {
if (changeLines.length > 0) { changes['activeFileChanged'] = {
changeLines.push(''); path: null,
} previousPath: lastActiveFile.path,
changeLines.push('Active file changed:'); };
changeLines.push(' No active file');
changeLines.push(` Previous path: ${lastActiveFile.path}`);
} }
if (changeLines.length === 0) { if (Object.keys(changes).length === 0) {
return { contextParts: [], newIdeContext: currentIdeContext }; return { contextParts: [], newIdeContext: currentIdeContext };
} }
delta['changes'] = changes;
const jsonString = JSON.stringify(delta, null, 2);
const contextParts = [ const contextParts = [
"Here is a summary of changes in the user's editor context. This is for your information only.", "Here is a summary of changes in the user's editor context, in JSON format. This is for your information only.",
changeLines.join('\n'), '```json',
jsonString,
'```',
]; ];
if (this.config.getDebugMode()) { if (this.config.getDebugMode()) {
@@ -569,6 +542,11 @@ export class GeminiClient {
} }
} }
if (!turn.pendingToolCalls.length && signal && !signal.aborted) { if (!turn.pendingToolCalls.length && signal && !signal.aborted) {
// Check if next speaker check is needed
if (this.config.getQuotaErrorOccurred()) {
return turn;
}
if (this.config.getSkipNextSpeakerCheck()) { if (this.config.getSkipNextSpeakerCheck()) {
return turn; return turn;
} }
@@ -624,11 +602,14 @@ export class GeminiClient {
}; };
const apiCall = () => { const apiCall = () => {
currentAttemptModel = model; const modelToUse = this.config.isInFallbackMode()
? DEFAULT_GEMINI_FLASH_MODEL
: model;
currentAttemptModel = modelToUse;
return this.getContentGeneratorOrFail().generateContent( return this.getContentGeneratorOrFail().generateContent(
{ {
model, model: modelToUse,
config: requestConfig, config: requestConfig,
contents, contents,
}, },

View File

@@ -5,11 +5,7 @@
*/ */
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { import { createContentGenerator, AuthType } from './contentGenerator.js';
createContentGenerator,
createContentGeneratorConfig,
AuthType,
} from './contentGenerator.js';
import { GoogleGenAI } from '@google/genai'; import { GoogleGenAI } from '@google/genai';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import { LoggingContentGenerator } from './loggingContentGenerator/index.js'; import { LoggingContentGenerator } from './loggingContentGenerator/index.js';
@@ -82,32 +78,3 @@ describe('createContentGenerator', () => {
expect(generator).toBeInstanceOf(LoggingContentGenerator); expect(generator).toBeInstanceOf(LoggingContentGenerator);
}); });
}); });
describe('createContentGeneratorConfig', () => {
const mockConfig = {
getProxy: () => undefined,
} as unknown as Config;
it('should preserve provided fields and set authType for QWEN_OAUTH', () => {
const cfg = createContentGeneratorConfig(mockConfig, AuthType.QWEN_OAUTH, {
model: 'vision-model',
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
});
expect(cfg.authType).toBe(AuthType.QWEN_OAUTH);
expect(cfg.model).toBe('vision-model');
expect(cfg.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
});
it('should not warn or fallback for QWEN_OAUTH (resolution handled by ModelConfigResolver)', () => {
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
const cfg = createContentGeneratorConfig(mockConfig, AuthType.QWEN_OAUTH, {
model: 'some-random-model',
});
expect(cfg.model).toBe('some-random-model');
expect(cfg.apiKey).toBeUndefined();
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});

View File

@@ -12,24 +12,9 @@ import type {
GenerateContentParameters, GenerateContentParameters,
GenerateContentResponse, GenerateContentResponse,
} from '@google/genai'; } from '@google/genai';
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import { LoggingContentGenerator } from './loggingContentGenerator/index.js'; import { LoggingContentGenerator } from './loggingContentGenerator/index.js';
import type {
ConfigSource,
ConfigSourceKind,
ConfigSources,
} from '../utils/configResolver.js';
import {
getDefaultApiKeyEnvVar,
getDefaultModelEnvVar,
MissingAnthropicBaseUrlEnvError,
MissingApiKeyError,
MissingBaseUrlError,
MissingModelError,
StrictMissingCredentialsError,
StrictMissingModelIdError,
} from '../models/modelConfigErrors.js';
import { PROVIDER_SOURCED_FIELDS } from '../models/modelsConfig.js';
/** /**
* Interface abstracting the core functionalities for generating content and counting tokens. * Interface abstracting the core functionalities for generating content and counting tokens.
@@ -63,7 +48,6 @@ export enum AuthType {
export type ContentGeneratorConfig = { export type ContentGeneratorConfig = {
model: string; model: string;
apiKey?: string; apiKey?: string;
apiKeyEnvKey?: string;
baseUrl?: string; baseUrl?: string;
vertexai?: boolean; vertexai?: boolean;
authType?: AuthType | undefined; authType?: AuthType | undefined;
@@ -93,178 +77,102 @@ export type ContentGeneratorConfig = {
schemaCompliance?: 'auto' | 'openapi_30'; schemaCompliance?: 'auto' | 'openapi_30';
}; };
// Keep the public ContentGeneratorConfigSources API, but reuse the generic
// source-tracking types from utils/configResolver to avoid duplication.
export type ContentGeneratorConfigSourceKind = ConfigSourceKind;
export type ContentGeneratorConfigSource = ConfigSource;
export type ContentGeneratorConfigSources = ConfigSources;
export type ResolvedContentGeneratorConfig = {
config: ContentGeneratorConfig;
sources: ContentGeneratorConfigSources;
};
function setSource(
sources: ContentGeneratorConfigSources,
path: string,
source: ContentGeneratorConfigSource,
): void {
sources[path] = source;
}
function getSeedSource(
seed: ContentGeneratorConfigSources | undefined,
path: string,
): ContentGeneratorConfigSource | undefined {
return seed?.[path];
}
/**
* Resolve ContentGeneratorConfig while tracking the source of each effective field.
*
* This function now primarily validates and finalizes the configuration that has
* already been resolved by ModelConfigResolver. The env fallback logic has been
* moved to the unified resolver to eliminate duplication.
*
* Note: The generationConfig passed here should already be fully resolved with
* proper source tracking from the caller (CLI/SDK layer).
*/
export function resolveContentGeneratorConfigWithSources(
config: Config,
authType: AuthType | undefined,
generationConfig?: Partial<ContentGeneratorConfig>,
seedSources?: ContentGeneratorConfigSources,
options?: { strictModelProvider?: boolean },
): ResolvedContentGeneratorConfig {
const sources: ContentGeneratorConfigSources = { ...(seedSources || {}) };
const strictModelProvider = options?.strictModelProvider === true;
// Build config with computed fields
const newContentGeneratorConfig: Partial<ContentGeneratorConfig> = {
...(generationConfig || {}),
authType,
proxy: config?.getProxy(),
};
// Set sources for computed fields
setSource(sources, 'authType', {
kind: 'computed',
detail: 'provided by caller',
});
if (config?.getProxy()) {
setSource(sources, 'proxy', {
kind: 'computed',
detail: 'Config.getProxy()',
});
}
// Preserve seed sources for fields that were passed in
const seedOrUnknown = (path: string): ContentGeneratorConfigSource =>
getSeedSource(seedSources, path) ?? { kind: 'unknown' };
for (const field of PROVIDER_SOURCED_FIELDS) {
if (generationConfig && field in generationConfig && !sources[field]) {
setSource(sources, field, seedOrUnknown(field));
}
}
// Validate required fields based on authType. This does not perform any
// fallback resolution (resolution is handled by ModelConfigResolver).
const validation = validateModelConfig(
newContentGeneratorConfig as ContentGeneratorConfig,
strictModelProvider,
);
if (!validation.valid) {
throw new Error(validation.errors.map((e) => e.message).join('\n'));
}
return {
config: newContentGeneratorConfig as ContentGeneratorConfig,
sources,
};
}
export interface ModelConfigValidationResult {
valid: boolean;
errors: Error[];
}
/**
* Validate a resolved model configuration.
* This is the single validation entry point used across Core.
*/
export function validateModelConfig(
config: ContentGeneratorConfig,
isStrictModelProvider: boolean = false,
): ModelConfigValidationResult {
const errors: Error[] = [];
// Qwen OAuth doesn't need validation - it uses dynamic tokens
if (config.authType === AuthType.QWEN_OAUTH) {
return { valid: true, errors: [] };
}
// API key is required for all other auth types
if (!config.apiKey) {
if (isStrictModelProvider) {
errors.push(
new StrictMissingCredentialsError(
config.authType,
config.model,
config.apiKeyEnvKey,
),
);
} else {
const envKey =
config.apiKeyEnvKey || getDefaultApiKeyEnvVar(config.authType);
errors.push(
new MissingApiKeyError({
authType: config.authType,
model: config.model,
baseUrl: config.baseUrl,
envKey,
}),
);
}
}
// Model is required
if (!config.model) {
if (isStrictModelProvider) {
errors.push(new StrictMissingModelIdError(config.authType));
} else {
const envKey = getDefaultModelEnvVar(config.authType);
errors.push(new MissingModelError({ authType: config.authType, envKey }));
}
}
// Explicit baseUrl is required for Anthropic; Migrated from existing code.
if (config.authType === AuthType.USE_ANTHROPIC && !config.baseUrl) {
if (isStrictModelProvider) {
errors.push(
new MissingBaseUrlError({
authType: config.authType,
model: config.model,
}),
);
} else if (config.authType === AuthType.USE_ANTHROPIC) {
errors.push(new MissingAnthropicBaseUrlEnvError());
}
}
return { valid: errors.length === 0, errors };
}
export function createContentGeneratorConfig( export function createContentGeneratorConfig(
config: Config, config: Config,
authType: AuthType | undefined, authType: AuthType | undefined,
generationConfig?: Partial<ContentGeneratorConfig>, generationConfig?: Partial<ContentGeneratorConfig>,
): ContentGeneratorConfig { ): ContentGeneratorConfig {
return resolveContentGeneratorConfigWithSources( let newContentGeneratorConfig: Partial<ContentGeneratorConfig> = {
config, ...(generationConfig || {}),
authType, authType,
generationConfig, proxy: config?.getProxy(),
).config; };
if (authType === AuthType.QWEN_OAUTH) {
// For Qwen OAuth, we'll handle the API key dynamically in createContentGenerator
// Set a special marker to indicate this is Qwen OAuth
return {
...newContentGeneratorConfig,
model: DEFAULT_QWEN_MODEL,
apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
} as ContentGeneratorConfig;
}
if (authType === AuthType.USE_OPENAI) {
newContentGeneratorConfig = {
...newContentGeneratorConfig,
apiKey: newContentGeneratorConfig.apiKey || process.env['OPENAI_API_KEY'],
baseUrl:
newContentGeneratorConfig.baseUrl || process.env['OPENAI_BASE_URL'],
model: newContentGeneratorConfig.model || process.env['OPENAI_MODEL'],
};
if (!newContentGeneratorConfig.apiKey) {
throw new Error('OPENAI_API_KEY environment variable not found.');
}
return {
...newContentGeneratorConfig,
model: newContentGeneratorConfig?.model || 'qwen3-coder-plus',
} as ContentGeneratorConfig;
}
if (authType === AuthType.USE_ANTHROPIC) {
newContentGeneratorConfig = {
...newContentGeneratorConfig,
apiKey:
newContentGeneratorConfig.apiKey || process.env['ANTHROPIC_API_KEY'],
baseUrl:
newContentGeneratorConfig.baseUrl || process.env['ANTHROPIC_BASE_URL'],
model: newContentGeneratorConfig.model || process.env['ANTHROPIC_MODEL'],
};
if (!newContentGeneratorConfig.apiKey) {
throw new Error('ANTHROPIC_API_KEY environment variable not found.');
}
if (!newContentGeneratorConfig.baseUrl) {
throw new Error('ANTHROPIC_BASE_URL environment variable not found.');
}
if (!newContentGeneratorConfig.model) {
throw new Error('ANTHROPIC_MODEL environment variable not found.');
}
}
if (authType === AuthType.USE_GEMINI) {
newContentGeneratorConfig = {
...newContentGeneratorConfig,
apiKey: newContentGeneratorConfig.apiKey || process.env['GEMINI_API_KEY'],
model: newContentGeneratorConfig.model || process.env['GEMINI_MODEL'],
};
if (!newContentGeneratorConfig.apiKey) {
throw new Error('GEMINI_API_KEY environment variable not found.');
}
if (!newContentGeneratorConfig.model) {
throw new Error('GEMINI_MODEL environment variable not found.');
}
}
if (authType === AuthType.USE_VERTEX_AI) {
newContentGeneratorConfig = {
...newContentGeneratorConfig,
apiKey: newContentGeneratorConfig.apiKey || process.env['GOOGLE_API_KEY'],
model: newContentGeneratorConfig.model || process.env['GOOGLE_MODEL'],
};
if (!newContentGeneratorConfig.apiKey) {
throw new Error('GOOGLE_API_KEY environment variable not found.');
}
if (!newContentGeneratorConfig.model) {
throw new Error('GOOGLE_MODEL environment variable not found.');
}
}
return newContentGeneratorConfig as ContentGeneratorConfig;
} }
export async function createContentGenerator( export async function createContentGenerator(
@@ -272,12 +180,11 @@ export async function createContentGenerator(
gcConfig: Config, gcConfig: Config,
isInitialAuth?: boolean, isInitialAuth?: boolean,
): Promise<ContentGenerator> { ): Promise<ContentGenerator> {
const validation = validateModelConfig(config, false);
if (!validation.valid) {
throw new Error(validation.errors.map((e) => e.message).join('\n'));
}
if (config.authType === AuthType.USE_OPENAI) { if (config.authType === AuthType.USE_OPENAI) {
if (!config.apiKey) {
throw new Error('OPENAI_API_KEY environment variable not found.');
}
// Import OpenAIContentGenerator dynamically to avoid circular dependencies // Import OpenAIContentGenerator dynamically to avoid circular dependencies
const { createOpenAIContentGenerator } = await import( const { createOpenAIContentGenerator } = await import(
'./openaiContentGenerator/index.js' './openaiContentGenerator/index.js'
@@ -316,6 +223,10 @@ export async function createContentGenerator(
} }
if (config.authType === AuthType.USE_ANTHROPIC) { if (config.authType === AuthType.USE_ANTHROPIC) {
if (!config.apiKey) {
throw new Error('ANTHROPIC_API_KEY environment variable not found.');
}
const { createAnthropicContentGenerator } = await import( const { createAnthropicContentGenerator } = await import(
'./anthropicContentGenerator/index.js' './anthropicContentGenerator/index.js'
); );

Some files were not shown because too many files have changed in this diff Show More