mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-16 22:09:13 +00:00
Compare commits
217 Commits
fix/missin
...
feat/cli-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
758e5c0992 | ||
|
|
881e7d038b | ||
|
|
5c6c3b2cf6 | ||
|
|
f4d4844364 | ||
|
|
b804b1f48a | ||
|
|
ff5ea3c6d7 | ||
|
|
0faaac8fa4 | ||
|
|
c2e62b9122 | ||
|
|
f54b62cda3 | ||
|
|
9521987a09 | ||
|
|
d20f2a41a2 | ||
|
|
e3eccb5987 | ||
|
|
22916457cd | ||
|
|
28bc4e6467 | ||
|
|
50bf65b10b | ||
|
|
47c8bc5303 | ||
|
|
e70ecdf3a8 | ||
|
|
117af05122 | ||
|
|
557e6397bb | ||
|
|
f762a62a2e | ||
|
|
ca12772a28 | ||
|
|
cec4b831b6 | ||
|
|
74bf72877d | ||
|
|
b60ae42d10 | ||
|
|
54fd4c22a9 | ||
|
|
f3b7c63cd1 | ||
|
|
e4dee3a2b2 | ||
|
|
996b9df947 | ||
|
|
64291db926 | ||
|
|
a8e3b9ebe7 | ||
|
|
5cfc9f4686 | ||
|
|
85473210e5 | ||
|
|
c0c94bd4fc | ||
|
|
8111511a89 | ||
|
|
a8eb858f99 | ||
|
|
52d6d1ff13 | ||
|
|
c845049d26 | ||
|
|
299b7de030 | ||
|
|
b93bb8bff6 | ||
|
|
adb53a6dc6 | ||
|
|
09196c6e19 | ||
|
|
4bd01d592b | ||
|
|
6917031128 | ||
|
|
b33525183f | ||
|
|
1aed5ce858 | ||
|
|
bad5b0485d | ||
|
|
5a6e5bb452 | ||
|
|
5f8e1ebc94 | ||
|
|
9670456a56 | ||
|
|
4c186e7c92 | ||
|
|
2f6b0b233a | ||
|
|
9a8ce605c5 | ||
|
|
afc693a4ab | ||
|
|
7173cba844 | ||
|
|
ec8cccafd7 | ||
|
|
8c56b612fb | ||
|
|
7d40e1470c | ||
|
|
b0e561ca73 | ||
|
|
563d68ad5b | ||
|
|
82c524f87d | ||
|
|
df75aa06b6 | ||
|
|
8ea9871d23 | ||
|
|
097482910e | ||
|
|
9b78c17638 | ||
|
|
bde31d1261 | ||
|
|
2d1934bf2f | ||
|
|
7f15256eba | ||
|
|
587fc82fbc | ||
|
|
cba9c424eb | ||
|
|
1b7418f91f | ||
|
|
b7828ac765 | ||
|
|
8705f734d0 | ||
|
|
0bd17a2406 | ||
|
|
59be5163fd | ||
|
|
95efe89ac0 | ||
|
|
6714f9ce3c | ||
|
|
155d1f9518 | ||
|
|
f776075aa8 | ||
|
|
36c142951a | ||
|
|
2b511d0b83 | ||
|
|
85bc0833b4 | ||
|
|
2662639280 | ||
|
|
d86903ced5 | ||
|
|
b7ac94ecf6 | ||
|
|
be8259b218 | ||
|
|
ca4c36f233 | ||
|
|
f41308f34c | ||
|
|
a47bdc0b06 | ||
|
|
0a33510304 | ||
|
|
0e769e100b | ||
|
|
82cbdee3b4 | ||
|
|
b5bcc07223 | ||
|
|
9653dc90d5 | ||
|
|
81de79c899 | ||
|
|
f6a753cf78 | ||
|
|
509d304742 | ||
|
|
6319a6ed56 | ||
|
|
ab07c2d89c | ||
|
|
5ea841dd02 | ||
|
|
ded1ebcdff | ||
|
|
afe6ba255e | ||
|
|
fe2ed889b9 | ||
|
|
8da376637a | ||
|
|
15f4c1ebd6 | ||
|
|
492da0c8c0 | ||
|
|
90855c93d1 | ||
|
|
db12796df5 | ||
|
|
aa9cdf2a3c | ||
|
|
052337861b | ||
|
|
0a0ab64da0 | ||
|
|
8a15017593 | ||
|
|
4d54a231b3 | ||
|
|
f8aecb2631 | ||
|
|
570ec432af | ||
|
|
bfc3bbfa9c | ||
|
|
91af9bf6c8 | ||
|
|
f6771c0858 | ||
|
|
2c8be05029 | ||
|
|
4744af1ea8 | ||
|
|
2c285394c7 | ||
|
|
0f1cb162c9 | ||
|
|
3d059b71de | ||
|
|
f2d941e469 | ||
|
|
9b2dfe1e06 | ||
|
|
3e695cd82b | ||
|
|
177a91f1d5 | ||
|
|
870d207f18 | ||
|
|
3f512528cb | ||
|
|
361492247e | ||
|
|
0878ee4cbd | ||
|
|
bfe7298858 | ||
|
|
2f2937aafe | ||
|
|
8fcdd86b91 | ||
|
|
d7d7bf0c39 | ||
|
|
b95d9a8d2d | ||
|
|
6f39ae120c | ||
|
|
627857621a | ||
|
|
65c7cf5d8f | ||
|
|
7a823060ac | ||
|
|
2c88ea6dc1 | ||
|
|
ad3086f7dd | ||
|
|
8f3bbef575 | ||
|
|
e2d6ab9b7e | ||
|
|
35bf5ef4d0 | ||
|
|
1d16513e27 | ||
|
|
731fd99800 | ||
|
|
c6ae0a8be7 | ||
|
|
87dc618a21 | ||
|
|
94a5d828bd | ||
|
|
49892a8e17 | ||
|
|
d1a3e828b7 | ||
|
|
824ca056a4 | ||
|
|
b19bb6cb20 | ||
|
|
e8625658ba | ||
|
|
19f8f631b4 | ||
|
|
a4eb3adea8 | ||
|
|
7dc7c6380d | ||
|
|
d2d2b845c5 | ||
|
|
96080f84a6 | ||
|
|
2b6218e564 | ||
|
|
24edf32da8 | ||
|
|
51b08f700c | ||
|
|
58eac7f595 | ||
|
|
32e8b01cf0 | ||
|
|
db9d5cb45d | ||
|
|
473cb7b951 | ||
|
|
e5cced8813 | ||
|
|
4f664d00ac | ||
|
|
7fdebe8fe6 | ||
|
|
73848d3867 | ||
|
|
6a62167f79 | ||
|
|
6ff437671e | ||
|
|
30f9e9c782 | ||
|
|
e4caa7a856 | ||
|
|
aaa66b3172 | ||
|
|
0ae59b900c | ||
|
|
5a5dae1987 | ||
|
|
ac7ba95d65 | ||
|
|
15912892f2 | ||
|
|
e3c20b03bd | ||
|
|
4db50d4158 | ||
|
|
4154493640 | ||
|
|
105ad743fa | ||
|
|
ac3f7cb8c8 | ||
|
|
98c043bf50 | ||
|
|
f610133660 | ||
|
|
fe7ff5b148 | ||
|
|
fd41309ed2 | ||
|
|
48bc0f35d7 | ||
|
|
e30c2dbe23 | ||
|
|
e9204ecba9 | ||
|
|
f24bda3d7b | ||
|
|
5417de4219 | ||
|
|
422998d7f0 | ||
|
|
68628bf952 | ||
|
|
e5efad89e0 | ||
|
|
e09bb5f5c0 | ||
|
|
24d11179d8 | ||
|
|
2ef8b6f350 | ||
|
|
5779f7ab1d | ||
|
|
43e0815def | ||
|
|
0c14f4ce08 | ||
|
|
34d8dbf9b2 | ||
|
|
b3b2bc6ad5 | ||
|
|
7b01b26ff5 | ||
|
|
0f3e97ea1c | ||
|
|
6ca54beba2 | ||
|
|
8673426d5c | ||
|
|
b272ac0119 | ||
|
|
574d89da14 | ||
|
|
16939c0bc8 | ||
|
|
6fc09a82fb | ||
|
|
d622f8d1bf | ||
|
|
28d178b5c1 | ||
|
|
4c69d536ac | ||
|
|
403fd06117 | ||
|
|
d9928eab66 |
3
.github/CODEOWNERS
vendored
Normal file
3
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy
|
||||
# SDK TypeScript package changes require review from Mingholy
|
||||
packages/sdk-typescript/** @Mingholy
|
||||
17
.github/workflows/release-sdk.yml
vendored
17
.github/workflows/release-sdk.yml
vendored
@@ -241,7 +241,7 @@ jobs:
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
id: 'pr'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
run: |-
|
||||
@@ -258,26 +258,15 @@ jobs:
|
||||
|
||||
echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Wait for CI checks to complete'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
echo "Waiting for CI checks to complete..."
|
||||
gh pr checks "${PR_URL}" --watch --interval 30
|
||||
|
||||
- name: 'Enable auto-merge for release PR'
|
||||
if: |-
|
||||
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
gh pr merge "${PR_URL}" --merge --auto
|
||||
gh pr merge "${PR_URL}" --merge --auto --delete-branch
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
if: |-
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@ package-lock.json
|
||||
.idea
|
||||
*.iml
|
||||
.cursor
|
||||
.qoder
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
|
||||
10
README.md
10
README.md
@@ -25,7 +25,7 @@ Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Code
|
||||
- **OpenAI-compatible, OAuth free tier**: use an OpenAI-compatible API, or sign in with Qwen OAuth to get 2,000 free requests/day.
|
||||
- **Open-source, co-evolving**: both the framework and the Qwen3-Coder model are open-source—and they ship and evolve together.
|
||||
- **Agentic workflow, feature-rich**: rich built-in tools (Skills, SubAgents, Plan Mode) for a full agentic workflow and a Claude Code-like experience.
|
||||
- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code and Zed.
|
||||
- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code, Zed, and JetBrains IDEs.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -137,10 +137,11 @@ Use `-p` to run Qwen Code without the interactive UI—ideal for scripts, automa
|
||||
|
||||
#### IDE integration
|
||||
|
||||
Use Qwen Code inside your editor (VS Code and Zed):
|
||||
Use Qwen Code inside your editor (VS Code, Zed, and JetBrains IDEs):
|
||||
|
||||
- [Use in VS Code](https://qwenlm.github.io/qwen-code-docs/en/users/integration-vscode/)
|
||||
- [Use in Zed](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/)
|
||||
- [Use in JetBrains IDEs](https://qwenlm.github.io/qwen-code-docs/en/users/integration-jetbrains/)
|
||||
|
||||
#### TypeScript SDK
|
||||
|
||||
@@ -200,6 +201,11 @@ If you encounter issues, check the [troubleshooting guide](https://qwenlm.github
|
||||
|
||||
To report a bug from within the CLI, run `/bug` and include a short title and repro steps.
|
||||
|
||||
## Connect with Us
|
||||
|
||||
- Discord: https://discord.gg/ycKBjdNd
|
||||
- Dingtalk: https://qr.dingtalk.com/action/joingroup?code=v1,k1,+FX6Gf/ZDlTahTIRi8AEQhIaBlqykA0j+eBKKdhLeAE=&_dt_no_comment=1&origin=1
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
This project is based on [Google Gemini CLI](https://github.com/google-gemini/gemini-cli). We acknowledge and appreciate the excellent work of the Gemini CLI team. Our main contribution focuses on parser-level adaptations to better support Qwen-Coder models.
|
||||
|
||||
@@ -11,6 +11,7 @@ export default {
|
||||
type: 'separator',
|
||||
},
|
||||
'sdk-typescript': 'Typescript SDK',
|
||||
'sdk-java': 'Java SDK(alpha)',
|
||||
'Dive Into Qwen Code': {
|
||||
title: 'Dive Into Qwen Code',
|
||||
type: 'separator',
|
||||
|
||||
312
docs/developers/sdk-java.md
Normal file
312
docs/developers/sdk-java.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# 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.
|
||||
@@ -10,4 +10,5 @@ export default {
|
||||
'web-search': 'Web Search',
|
||||
memory: 'Memory',
|
||||
'mcp-server': 'MCP Servers',
|
||||
sandbox: 'Sandboxing',
|
||||
};
|
||||
|
||||
90
docs/developers/tools/sandbox.md
Normal file
90
docs/developers/tools/sandbox.md
Normal file
@@ -0,0 +1,90 @@
|
||||
## Customizing the sandbox environment (Docker/Podman)
|
||||
|
||||
### Currently, the project does not support the use of the BUILD_SANDBOX function after installation through the npm package
|
||||
|
||||
1. To build a custom sandbox, you need to access the build scripts (scripts/build_sandbox.js) in the source code repository.
|
||||
2. These build scripts are not included in the packages released by npm.
|
||||
3. The code contains hard-coded path checks that explicitly reject build requests from non-source code environments.
|
||||
|
||||
If you need extra tools inside the container (e.g., `git`, `python`, `rg`), create a custom Dockerfile, The specific operation is as follows
|
||||
|
||||
#### 1、Clone qwen code project first, https://github.com/QwenLM/qwen-code.git
|
||||
|
||||
#### 2、Make sure you perform the following operation in the source code repository directory
|
||||
|
||||
```bash
|
||||
# 1. First, install the dependencies of the project
|
||||
npm install
|
||||
|
||||
# 2. Build the Qwen Code project
|
||||
npm run build
|
||||
|
||||
# 3. Verify that the dist directory has been generated
|
||||
ls -la packages/cli/dist/
|
||||
|
||||
# 4. Create a global link in the CLI package directory
|
||||
cd packages/cli
|
||||
npm link
|
||||
|
||||
# 5. Verification link (it should now point to the source code)
|
||||
which qwen
|
||||
# Expected output: /xxx/xxx/.nvm/versions/node/v24.11.1/bin/qwen
|
||||
# Or similar paths, but it should be a symbolic link
|
||||
|
||||
# 6. For details of the symbolic link, you can see the specific source code path
|
||||
ls -la $(dirname $(which qwen))/../lib/node_modules/@qwen-code/qwen-code
|
||||
# It should show that this is a symbolic link pointing to your source code directory
|
||||
|
||||
# 7.Test the version of qwen
|
||||
qwen -v
|
||||
# npm link will overwrite the global qwen. To avoid being unable to distinguish the same version number, you can uninstall the global CLI first
|
||||
```
|
||||
|
||||
#### 3、Create your sandbox Dockerfile under the root directory of your own project
|
||||
|
||||
- Path: `.qwen/sandbox.Dockerfile`
|
||||
|
||||
- Official mirror image address:https://github.com/QwenLM/qwen-code/pkgs/container/qwen-code
|
||||
|
||||
```bash
|
||||
# Based on the official Qwen sandbox image (It is recommended to explicitly specify the version)
|
||||
FROM ghcr.io/qwenlm/qwen-code:sha-570ec43
|
||||
# Add your extra tools here
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
python3 \
|
||||
ripgrep
|
||||
```
|
||||
|
||||
#### 4、Create the first sandbox image under the root directory of your project
|
||||
|
||||
```bash
|
||||
GEMINI_SANDBOX=docker BUILD_SANDBOX=1 qwen -s
|
||||
# Observe whether the sandbox version of the tool you launched is consistent with the version of your custom image. If they are consistent, the startup will be successful
|
||||
```
|
||||
|
||||
This builds a project-specific image based on the default sandbox image.
|
||||
|
||||
#### Remove npm link
|
||||
|
||||
- If you want to restore the official CLI of qwen, please remove the npm link
|
||||
|
||||
```bash
|
||||
# Method 1: Unlink globally
|
||||
npm unlink -g @qwen-code/qwen-code
|
||||
|
||||
# Method 2: Remove it in the packages/cli directory
|
||||
cd packages/cli
|
||||
npm unlink
|
||||
|
||||
# Verification has been lifted
|
||||
which qwen
|
||||
# It should display "qwen not found"
|
||||
|
||||
# Reinstall the global version if necessary
|
||||
npm install -g @qwen-code/qwen-code
|
||||
|
||||
# Verification Recovery
|
||||
which qwen
|
||||
qwen --version
|
||||
```
|
||||
@@ -12,6 +12,7 @@ export default {
|
||||
},
|
||||
'integration-vscode': 'Visual Studio Code',
|
||||
'integration-zed': 'Zed IDE',
|
||||
'integration-jetbrains': 'JetBrains IDEs',
|
||||
'integration-github-action': 'Github Actions',
|
||||
'Code with Qwen Code': {
|
||||
type: 'separator',
|
||||
|
||||
@@ -74,9 +74,6 @@ Settings are organized into categories. All settings should be placed within the
|
||||
| `ui.customThemes` | object | Custom theme definitions. | `{}` |
|
||||
| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` |
|
||||
| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` |
|
||||
| `ui.hideBanner` | boolean | Hide the application banner. | `false` |
|
||||
| `ui.hideFooter` | boolean | Hide the footer from the UI. | `false` |
|
||||
| `ui.showMemoryUsage` | boolean | Display memory usage information in the UI. | `false` |
|
||||
| `ui.showLineNumbers` | boolean | Show line numbers in code blocks in the CLI output. | `true` |
|
||||
| `ui.showCitations` | boolean | Show citations for generated text in the chat. | `true` |
|
||||
| `enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` |
|
||||
@@ -104,7 +101,7 @@ Settings are organized into categories. All settings should be placed within the
|
||||
| `model.name` | string | The Qwen model to use for conversations. | `undefined` |
|
||||
| `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
|
||||
| `model.summarizeToolOutput` | object | Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}` | `undefined` |
|
||||
| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, and `disableCacheControl`, along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` |
|
||||
| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `disableCacheControl`, and `customHeaders` (custom HTTP headers for API requests), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` |
|
||||
| `model.chatCompression.contextPercentageThreshold` | number | Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. | `0.7` |
|
||||
| `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` |
|
||||
| `model.skipLoopDetection` | boolean | Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. | `false` |
|
||||
@@ -114,12 +111,16 @@ Settings are organized into categories. All settings should be placed within the
|
||||
|
||||
**Example model.generationConfig:**
|
||||
|
||||
```
|
||||
```json
|
||||
{
|
||||
"model": {
|
||||
"generationConfig": {
|
||||
"timeout": 60000,
|
||||
"disableCacheControl": false,
|
||||
"customHeaders": {
|
||||
"X-Request-ID": "req-123",
|
||||
"X-User-ID": "user-456"
|
||||
},
|
||||
"samplingParams": {
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.8,
|
||||
@@ -130,12 +131,107 @@ Settings are organized into categories. All settings should be placed within the
|
||||
}
|
||||
```
|
||||
|
||||
The `customHeaders` field allows you to add custom HTTP headers to all API requests. This is useful for request tracing, monitoring, API gateway routing, or when different models require different headers. If `customHeaders` is defined in `modelProviders[].generationConfig.customHeaders`, it will be used directly; otherwise, headers from `model.generationConfig.customHeaders` will be used. No merging occurs between the two levels.
|
||||
|
||||
**model.openAILoggingDir examples:**
|
||||
|
||||
- `"~/qwen-logs"` - Logs to `~/qwen-logs` directory
|
||||
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
|
||||
- `"/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,
|
||||
"customHeaders": {
|
||||
"X-Model-Version": "v1.0",
|
||||
"X-Request-Priority": "high"
|
||||
},
|
||||
"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` and `customHeaders` are both treated atomically; provider values replace the entire object. If `modelProviders[].generationConfig` defines these fields, they are used directly; otherwise, values from `model.generationConfig` are used. No merging occurs between provider and global configuration levels. 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
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
@@ -257,7 +353,6 @@ Here is an example of a `settings.json` file with the nested structure, new as o
|
||||
},
|
||||
"ui": {
|
||||
"theme": "GitHub",
|
||||
"hideBanner": true,
|
||||
"hideTips": false,
|
||||
"customWittyPhrases": [
|
||||
"You forget a thousand things every day. Make sure this is one of 'em",
|
||||
@@ -381,7 +476,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-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
|
||||
| `--experimental-acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Experimental. |
|
||||
| `--acp` | | Enables ACP mode (Agent Client Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. |
|
||||
| `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. |
|
||||
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
|
||||
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
|
||||
|
||||
@@ -59,6 +59,7 @@ Commands for managing AI tools and models.
|
||||
| ---------------- | --------------------------------------------- | --------------------------------------------- |
|
||||
| `/mcp` | List configured MCP servers and tools | `/mcp`, `/mcp desc` |
|
||||
| `/tools` | Display currently available tool list | `/tools`, `/tools desc` |
|
||||
| `/skills` | List and run available skills (experimental) | `/skills`, `/skills <name>` |
|
||||
| `/approval-mode` | Change approval mode for tool usage | `/approval-mode <mode (auto-edit)> --project` |
|
||||
| →`plan` | Analysis only, no execution | Secure review |
|
||||
| →`default` | Require approval for edits | Daily use |
|
||||
|
||||
@@ -49,6 +49,8 @@ Cross-platform sandboxing with complete process isolation.
|
||||
|
||||
By default, Qwen Code uses a published sandbox image (configured in the CLI package) and will pull it as needed.
|
||||
|
||||
The container sandbox mounts your workspace and your `~/.qwen` directory into the container so auth and settings persist between runs.
|
||||
|
||||
**Best for**: Strong isolation on any OS, consistent tooling inside a known image.
|
||||
|
||||
### Choosing a method
|
||||
@@ -157,22 +159,13 @@ For a working allowlist-style proxy example, see: [Example Proxy Script](/develo
|
||||
|
||||
## Linux UID/GID handling
|
||||
|
||||
The sandbox automatically handles user permissions on Linux. Override these permissions with:
|
||||
On Linux, Qwen Code defaults to enabling UID/GID mapping so the sandbox runs as your user (and reuses the mounted `~/.qwen`). Override with:
|
||||
|
||||
```bash
|
||||
export SANDBOX_SET_UID_GID=true # Force host UID/GID
|
||||
export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping
|
||||
```
|
||||
|
||||
## Customizing the sandbox environment (Docker/Podman)
|
||||
|
||||
If you need extra tools inside the container (e.g., `git`, `python`, `rg`), create a custom Dockerfile:
|
||||
|
||||
- Path: `.qwen/sandbox.Dockerfile`
|
||||
- Then run with: `BUILD_SANDBOX=1 qwen -s ...`
|
||||
|
||||
This builds a project-specific image based on the default sandbox image.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common issues
|
||||
|
||||
@@ -27,6 +27,14 @@ Agent Skills package expertise into discoverable capabilities. Each Skill consis
|
||||
|
||||
Skills are **model-invoked** — the model autonomously decides when to use them based on your request and the Skill’s description. This is different from slash commands, which are **user-invoked** (you explicitly type `/command`).
|
||||
|
||||
If you want to invoke a Skill explicitly, use the `/skills` slash command:
|
||||
|
||||
```bash
|
||||
/skills <skill-name>
|
||||
```
|
||||
|
||||
The `/skills` command is only available when you run with `--experimental-skills`. Use autocomplete to browse available Skills and descriptions.
|
||||
|
||||
### Benefits
|
||||
|
||||
- Extend Qwen Code for your workflows
|
||||
|
||||
57
docs/users/integration-jetbrains.md
Normal file
57
docs/users/integration-jetbrains.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# JetBrains IDEs
|
||||
|
||||
> JetBrains IDEs provide native support for AI coding assistants through the Agent Client Protocol (ACP). This integration allows you to use Qwen Code directly within your JetBrains IDE with real-time code suggestions.
|
||||
|
||||
### Features
|
||||
|
||||
- **Native agent experience**: Integrated AI assistant panel within your JetBrains IDE
|
||||
- **Agent Client Protocol**: Full support for ACP enabling advanced IDE interactions
|
||||
- **Symbol management**: #-mention files to add them to the conversation context
|
||||
- **Conversation history**: Access to past conversations within the IDE
|
||||
|
||||
### Requirements
|
||||
|
||||
- JetBrains IDE with ACP support (IntelliJ IDEA, WebStorm, PyCharm, etc.)
|
||||
- Qwen Code CLI installed
|
||||
|
||||
### Installation
|
||||
|
||||
1. Install Qwen Code CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @qwen-code/qwen-code
|
||||
```
|
||||
|
||||
2. Open your JetBrains IDE and navigate to AI Chat tool window.
|
||||
|
||||
3. Click the 3-dot menu in the upper-right corner and select **Configure ACP Agent** and configure Qwen Code with the following settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"qwen": {
|
||||
"command": "/path/to/qwen",
|
||||
"args": ["--acp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. The Qwen Code agent should now be available in the AI Assistant panel
|
||||
|
||||

|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent not appearing
|
||||
|
||||
- Run `qwen --version` in terminal to verify installation
|
||||
- Ensure your JetBrains IDE version supports ACP
|
||||
- Restart your JetBrains IDE
|
||||
|
||||
### Qwen Code not responding
|
||||
|
||||
- Check your internet connection
|
||||
- Verify CLI works by running `qwen` in terminal
|
||||
- [File an issue on GitHub](https://github.com/qwenlm/qwen-code/issues) if the problem persists
|
||||
@@ -18,23 +18,17 @@
|
||||
|
||||
### Requirements
|
||||
|
||||
- VS Code 1.98.0 or higher
|
||||
- VS Code 1.85.0 or higher
|
||||
|
||||
### Installation
|
||||
|
||||
1. Install Qwen Code CLI:
|
||||
|
||||
```bash
|
||||
npm install -g qwen-code
|
||||
```
|
||||
|
||||
2. Download and install the extension from the [Visual Studio Code Extension Marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion).
|
||||
Download and install the extension from the [Visual Studio Code Extension Marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Extension not installing
|
||||
|
||||
- Ensure you have VS Code 1.98.0 or higher
|
||||
- Ensure you have VS Code 1.85.0 or higher
|
||||
- Check that VS Code has permission to install extensions
|
||||
- Try installing directly from the Marketplace website
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Zed Editor
|
||||
|
||||
> Zed Editor provides native support for AI coding assistants through the Agent Control Protocol (ACP). This integration allows you to use Qwen Code directly within Zed's interface with real-time code suggestions.
|
||||
> Zed Editor provides native support for AI coding assistants through the Agent Client Protocol (ACP). This integration allows you to use Qwen Code directly within Zed's interface with real-time code suggestions.
|
||||
|
||||

|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
|
||||
1. Install Qwen Code CLI:
|
||||
|
||||
```bash
|
||||
npm install -g qwen-code
|
||||
```
|
||||
```bash
|
||||
npm install -g @qwen-code/qwen-code
|
||||
```
|
||||
|
||||
2. Download and install [Zed Editor](https://zed.dev/)
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"Qwen Code": {
|
||||
"type": "custom",
|
||||
"command": "qwen",
|
||||
"args": ["--experimental-acp"],
|
||||
"args": ["--acp"],
|
||||
"env": {}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Qwen Code overview
|
||||
[](https://npm-compare.com/@qwen-code/qwen-code)
|
||||
|
||||
[](https://npm-compare.com/@qwen-code/qwen-code)
|
||||
[](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.
|
||||
|
||||
@@ -159,7 +159,7 @@ Qwen Code will:
|
||||
|
||||
### Test out other common workflows
|
||||
|
||||
There are a number of ways to work with Claude:
|
||||
There are a number of ways to work with Qwen Code:
|
||||
|
||||
**Refactor code**
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ This document lists the available keyboard shortcuts in Qwen Code.
|
||||
| Shortcut | Description |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `!` | Toggle shell mode when the input is empty. |
|
||||
| `?` | Toggle keyboard shortcuts display when the input is empty. |
|
||||
| `\` (at end of line) + `Enter` | Insert a newline. |
|
||||
| `Down Arrow` | Navigate down through the input history. |
|
||||
| `Enter` | Submit the current prompt. |
|
||||
@@ -38,6 +39,7 @@ This document lists the available keyboard shortcuts in Qwen Code.
|
||||
| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. |
|
||||
| `Ctrl+N` | Navigate down through the input history. |
|
||||
| `Ctrl+P` | Navigate up through the input history. |
|
||||
| `Ctrl+R` | Reverse search through input/shell history. |
|
||||
| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
|
||||
| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
|
||||
| `Ctrl+V` | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
|
||||
|
||||
@@ -9,11 +9,18 @@ This guide provides solutions to common issues and debugging tips, including top
|
||||
|
||||
## Authentication or login errors
|
||||
|
||||
- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` or `unable to get local issuer certificate`**
|
||||
- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, or `unable to get local issuer certificate`**
|
||||
- **Cause:** You may be on a corporate network with a firewall that intercepts and inspects SSL/TLS traffic. This often requires a custom root CA certificate to be trusted by Node.js.
|
||||
- **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file.
|
||||
- Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt`
|
||||
|
||||
- **Error: `Device authorization flow failed: fetch failed`**
|
||||
- **Cause:** Node.js could not reach Qwen OAuth endpoints (often a proxy or SSL/TLS trust issue). When available, Qwen Code will also print the underlying error cause (for example: `UNABLE_TO_VERIFY_LEAF_SIGNATURE`).
|
||||
- **Solution:**
|
||||
- Confirm you can access `https://chat.qwen.ai` from the same machine/network.
|
||||
- If you are behind a proxy, set it via `qwen --proxy <url>` (or the `proxy` setting in `settings.json`).
|
||||
- If your network uses a corporate TLS inspection CA, set `NODE_EXTRA_CA_CERTS` as described above.
|
||||
|
||||
- **Issue: Unable to display UI after authentication failure**
|
||||
- **Cause:** If authentication fails after selecting an authentication type, the `security.auth.selectedType` setting may be persisted in `settings.json`. On restart, the CLI may get stuck trying to authenticate with the failed auth type and fail to display the UI.
|
||||
- **Solution:** Clear the `security.auth.selectedType` configuration item in your `settings.json` file:
|
||||
|
||||
@@ -80,10 +80,11 @@ type PermissionHandler = (
|
||||
|
||||
/**
|
||||
* 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(
|
||||
rig: TestRig,
|
||||
options?: { permissionHandler?: PermissionHandler },
|
||||
options?: { permissionHandler?: PermissionHandler; useNewFlag?: boolean },
|
||||
) {
|
||||
const pending = new Map<number, PendingRequest>();
|
||||
let nextRequestId = 1;
|
||||
@@ -95,9 +96,13 @@ function setupAcpTest(
|
||||
const permissionHandler =
|
||||
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(
|
||||
'node',
|
||||
[rig.bundlePath, '--experimental-acp', '--no-chat-recording'],
|
||||
[rig.bundlePath, acpFlag, '--no-chat-recording'],
|
||||
{
|
||||
cwd: rig.testDir!,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -621,3 +626,99 @@ 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();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -831,7 +831,7 @@ describe('Permission Control (E2E)', () => {
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
it.skip(
|
||||
'should execute dangerous commands without confirmation',
|
||||
async () => {
|
||||
const q = query({
|
||||
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -6216,10 +6216,7 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
@@ -13882,10 +13879,7 @@
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
@@ -17316,7 +17310,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -17953,7 +17947,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.36.1",
|
||||
@@ -17974,6 +17968,7 @@
|
||||
"ajv-formats": "^3.0.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chardet": "^2.1.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
@@ -18593,7 +18588,7 @@
|
||||
},
|
||||
"packages/sdk-typescript": {
|
||||
"name": "@qwen-code/sdk",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
@@ -21413,7 +21408,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
@@ -21425,7 +21420,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,7 +33,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
|
||||
@@ -1,41 +1,112 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { vi } from 'vitest';
|
||||
import { validateAuthMethod } from './auth.js';
|
||||
import * as settings from './settings.js';
|
||||
|
||||
vi.mock('./settings.js', () => ({
|
||||
loadEnvironment: vi.fn(),
|
||||
loadSettings: vi.fn().mockReturnValue({
|
||||
merged: vi.fn().mockReturnValue({}),
|
||||
merged: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('validateAuthMethod', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
// Reset mock to default
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {},
|
||||
} as ReturnType<typeof settings.loadSettings>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
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', () => {
|
||||
it('should return null for USE_OPENAI with default env key', () => {
|
||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return an error message for USE_OPENAI if OPENAI_API_KEY is not set', () => {
|
||||
delete process.env['OPENAI_API_KEY'];
|
||||
it('should return an error message for USE_OPENAI if no API key is available', () => {
|
||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe(
|
||||
'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.',
|
||||
"Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable.",
|
||||
);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull();
|
||||
});
|
||||
@@ -45,4 +116,115 @@ describe('validateAuthMethod', () => {
|
||||
'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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,169 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { loadEnvironment, loadSettings } from './settings.js';
|
||||
import {
|
||||
AuthType,
|
||||
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();
|
||||
loadEnvironment(settings.merged);
|
||||
|
||||
if (authMethod === AuthType.USE_OPENAI) {
|
||||
const hasApiKey =
|
||||
process.env['OPENAI_API_KEY'] || settings.merged.security?.auth?.apiKey;
|
||||
if (!hasApiKey) {
|
||||
return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.';
|
||||
const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth(
|
||||
authMethod,
|
||||
settings.merged,
|
||||
config,
|
||||
);
|
||||
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;
|
||||
}
|
||||
@@ -27,36 +175,49 @@ export function validateAuthMethod(authMethod: string): string | null {
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_ANTHROPIC) {
|
||||
const hasApiKey = process.env['ANTHROPIC_API_KEY'];
|
||||
if (!hasApiKey) {
|
||||
return 'ANTHROPIC_API_KEY environment variable not found.';
|
||||
const apiKeyError = getApiKeyError(authMethod, settings.merged, config);
|
||||
if (apiKeyError) {
|
||||
return apiKeyError;
|
||||
}
|
||||
|
||||
const hasBaseUrl = process.env['ANTHROPIC_BASE_URL'];
|
||||
if (!hasBaseUrl) {
|
||||
return 'ANTHROPIC_BASE_URL environment variable not found.';
|
||||
// Check baseUrl - can come from modelProviders or environment
|
||||
const modelProviders = settings.merged.modelProviders as
|
||||
| ModelProvidersConfig
|
||||
| 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;
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_GEMINI) {
|
||||
const hasApiKey = process.env['GEMINI_API_KEY'];
|
||||
if (!hasApiKey) {
|
||||
return 'GEMINI_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
|
||||
const apiKeyError = getApiKeyError(authMethod, settings.merged, config);
|
||||
if (apiKeyError) {
|
||||
return apiKeyError;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_VERTEX_AI) {
|
||||
const hasApiKey = process.env['GOOGLE_API_KEY'];
|
||||
if (!hasApiKey) {
|
||||
return 'GOOGLE_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
|
||||
const apiKeyError = getApiKeyError(authMethod, settings.merged, config);
|
||||
if (apiKeyError) {
|
||||
return apiKeyError;
|
||||
}
|
||||
|
||||
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'Invalid auth method selected.';
|
||||
return t('Invalid auth method selected.');
|
||||
}
|
||||
|
||||
@@ -77,10 +77,8 @@ vi.mock('read-package-up', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
const actualServer = await vi.importActual<typeof ServerConfig>(
|
||||
'@qwen-code/qwen-code-core',
|
||||
);
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actualServer = await importOriginal<typeof ServerConfig>();
|
||||
return {
|
||||
...actualServer,
|
||||
IdeClient: {
|
||||
@@ -555,70 +553,6 @@ describe('loadCliConfig', () => {
|
||||
expect(config.getIncludePartialMessages()).toBe(true);
|
||||
});
|
||||
|
||||
it('should set showMemoryUsage to true when --show-memory-usage flag is present', async () => {
|
||||
process.argv = ['node', 'script.js', '--show-memory-usage'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
[],
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
argv,
|
||||
);
|
||||
expect(config.getShowMemoryUsage()).toBe(true);
|
||||
});
|
||||
|
||||
it('should set showMemoryUsage to false when --memory flag is not present', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
[],
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
argv,
|
||||
);
|
||||
expect(config.getShowMemoryUsage()).toBe(false);
|
||||
});
|
||||
|
||||
it('should set showMemoryUsage to false by default from settings if CLI flag is not present', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = { ui: { showMemoryUsage: false } };
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
[],
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
argv,
|
||||
);
|
||||
expect(config.getShowMemoryUsage()).toBe(false);
|
||||
});
|
||||
|
||||
it('should prioritize CLI flag over settings for showMemoryUsage (CLI true, settings false)', async () => {
|
||||
process.argv = ['node', 'script.js', '--show-memory-usage'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = { ui: { showMemoryUsage: false } };
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
[],
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
argv,
|
||||
);
|
||||
expect(config.getShowMemoryUsage()).toBe(true);
|
||||
});
|
||||
|
||||
describe('Proxy configuration', () => {
|
||||
const originalProxyEnv: { [key: string]: string | undefined } = {};
|
||||
const proxyEnvVars = [
|
||||
@@ -1597,6 +1531,58 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
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 () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
|
||||
@@ -10,25 +10,31 @@ import {
|
||||
Config,
|
||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
EditTool,
|
||||
FileDiscoveryService,
|
||||
getCurrentGeminiMdFilename,
|
||||
loadServerHierarchicalMemory,
|
||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||
ShellTool,
|
||||
WriteFileTool,
|
||||
resolveTelemetrySettings,
|
||||
FatalConfigError,
|
||||
Storage,
|
||||
InputFormat,
|
||||
OutputFormat,
|
||||
isToolEnabled,
|
||||
SessionService,
|
||||
type ResumedSessionData,
|
||||
type FileFilteringOptions,
|
||||
type MCPServerConfig,
|
||||
type ToolName,
|
||||
EditTool,
|
||||
ShellTool,
|
||||
WriteFileTool,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import {
|
||||
resolveCliGenerationConfig,
|
||||
getAuthTypeFromEnv,
|
||||
} from '../utils/modelConfigUtils.js';
|
||||
import yargs, { type Argv } from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import * as fs from 'node:fs';
|
||||
@@ -99,7 +105,6 @@ export interface CliArgs {
|
||||
prompt: string | undefined;
|
||||
promptInteractive: string | undefined;
|
||||
allFiles: boolean | undefined;
|
||||
showMemoryUsage: boolean | undefined;
|
||||
yolo: boolean | undefined;
|
||||
approvalMode: string | undefined;
|
||||
telemetry: boolean | undefined;
|
||||
@@ -111,6 +116,7 @@ export interface CliArgs {
|
||||
telemetryOutfile: string | undefined;
|
||||
allowedMcpServerNames: string[] | undefined;
|
||||
allowedTools: string[] | undefined;
|
||||
acp: boolean | undefined;
|
||||
experimentalAcp: boolean | undefined;
|
||||
experimentalSkills: boolean | undefined;
|
||||
extensions: string[] | undefined;
|
||||
@@ -163,7 +169,17 @@ function normalizeOutputFormat(
|
||||
}
|
||||
|
||||
export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
const rawArgv = hideBin(process.argv);
|
||||
let rawArgv = hideBin(process.argv);
|
||||
|
||||
// hack: if the first argument is the CLI entry point, remove it
|
||||
if (
|
||||
rawArgv.length > 0 &&
|
||||
(rawArgv[0].endsWith('/dist/qwen-cli/cli.js') ||
|
||||
rawArgv[0].endsWith('/dist/cli.js'))
|
||||
) {
|
||||
rawArgv = rawArgv.slice(1);
|
||||
}
|
||||
|
||||
const yargsInstance = yargs(rawArgv)
|
||||
.locale('en')
|
||||
.scriptName('qwen')
|
||||
@@ -281,11 +297,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
description: 'Include ALL files in context?',
|
||||
default: false,
|
||||
})
|
||||
.option('show-memory-usage', {
|
||||
type: 'boolean',
|
||||
description: 'Show memory usage in status bar',
|
||||
default: false,
|
||||
})
|
||||
.option('yolo', {
|
||||
alias: 'y',
|
||||
type: 'boolean',
|
||||
@@ -304,10 +315,16 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
description: 'Enables checkpointing of file edits',
|
||||
default: false,
|
||||
})
|
||||
.option('experimental-acp', {
|
||||
.option('acp', {
|
||||
type: 'boolean',
|
||||
description: 'Starts the agent in ACP mode',
|
||||
})
|
||||
.option('experimental-acp', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Starts the agent in ACP mode (deprecated, use --acp instead)',
|
||||
hidden: true,
|
||||
})
|
||||
.option('experimental-skills', {
|
||||
type: 'boolean',
|
||||
description: 'Enable experimental Skills feature',
|
||||
@@ -475,10 +492,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
],
|
||||
description: 'Authentication type',
|
||||
})
|
||||
.deprecateOption(
|
||||
'show-memory-usage',
|
||||
'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'sandbox-image',
|
||||
'Use the "tools.sandbox" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
@@ -589,8 +602,19 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
|
||||
// Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
|
||||
if (result['experimentalAcp'] && !result['channel']) {
|
||||
// Handle deprecated --experimental-acp flag
|
||||
if (result['experimentalAcp']) {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -818,6 +842,28 @@ export async function loadCliConfig(
|
||||
// However, if stream-json input is used, control can be requested via JSON messages,
|
||||
// so tools should not be excluded in that case.
|
||||
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 (
|
||||
!interactive &&
|
||||
!argv.experimentalAcp &&
|
||||
@@ -826,12 +872,15 @@ export async function loadCliConfig(
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.PLAN:
|
||||
case ApprovalMode.DEFAULT:
|
||||
// In default non-interactive mode, all tools that require approval are excluded.
|
||||
extraExcludes.push(ShellTool.Name, EditTool.Name, WriteFileTool.Name);
|
||||
// In default non-interactive mode, all tools that require approval are excluded,
|
||||
// unless explicitly enabled via coreTools/allowedTools.
|
||||
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
||||
excludeUnlessExplicit(EditTool.Name as ToolName);
|
||||
excludeUnlessExplicit(WriteFileTool.Name as ToolName);
|
||||
break;
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
|
||||
extraExcludes.push(ShellTool.Name);
|
||||
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
||||
break;
|
||||
case ApprovalMode.YOLO:
|
||||
// No extra excludes for YOLO mode.
|
||||
@@ -879,28 +928,25 @@ export async function loadCliConfig(
|
||||
|
||||
const selectedAuthType =
|
||||
(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();
|
||||
|
||||
const apiKey =
|
||||
(selectedAuthType === AuthType.USE_OPENAI
|
||||
? argv.openaiApiKey ||
|
||||
process.env['OPENAI_API_KEY'] ||
|
||||
settings.security?.auth?.apiKey
|
||||
: '') || '';
|
||||
const baseUrl =
|
||||
(selectedAuthType === AuthType.USE_OPENAI
|
||||
? argv.openaiBaseUrl ||
|
||||
process.env['OPENAI_BASE_URL'] ||
|
||||
settings.security?.auth?.baseUrl
|
||||
: '') || '';
|
||||
const resolvedModel =
|
||||
argv.model ||
|
||||
(selectedAuthType === AuthType.USE_OPENAI
|
||||
? process.env['OPENAI_MODEL'] ||
|
||||
process.env['QWEN_MODEL'] ||
|
||||
settings.model?.name
|
||||
: '') ||
|
||||
'';
|
||||
// Unified resolution of generation config with source attribution
|
||||
const resolvedCliConfig = resolveCliGenerationConfig({
|
||||
argv: {
|
||||
model: argv.model,
|
||||
openaiApiKey: argv.openaiApiKey,
|
||||
openaiBaseUrl: argv.openaiBaseUrl,
|
||||
openaiLogging: argv.openaiLogging,
|
||||
openaiLoggingDir: argv.openaiLoggingDir,
|
||||
},
|
||||
settings,
|
||||
selectedAuthType,
|
||||
env: process.env as Record<string, string | undefined>,
|
||||
});
|
||||
|
||||
const { model: resolvedModel } = resolvedCliConfig;
|
||||
|
||||
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
||||
const screenReader =
|
||||
@@ -934,6 +980,8 @@ export async function loadCliConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const modelProvidersConfig = settings.modelProviders;
|
||||
|
||||
return new Config({
|
||||
sessionId,
|
||||
sessionData,
|
||||
@@ -956,8 +1004,6 @@ export async function loadCliConfig(
|
||||
userMemory: memoryContent,
|
||||
geminiMdFileCount: fileCount,
|
||||
approvalMode,
|
||||
showMemoryUsage:
|
||||
argv.showMemoryUsage || settings.ui?.showMemoryUsage || false,
|
||||
accessibility: {
|
||||
...settings.ui?.accessibility,
|
||||
screenReader,
|
||||
@@ -981,7 +1027,7 @@ export async function loadCliConfig(
|
||||
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
|
||||
maxSessionTurns:
|
||||
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
|
||||
experimentalZedIntegration: argv.experimentalAcp || false,
|
||||
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
|
||||
experimentalSkills: argv.experimentalSkills || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
extensions: allExtensions,
|
||||
@@ -991,24 +1037,11 @@ export async function loadCliConfig(
|
||||
inputFormat,
|
||||
outputFormat,
|
||||
includePartialMessages,
|
||||
generationConfig: {
|
||||
...(settings.model?.generationConfig || {}),
|
||||
model: resolvedModel,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
enableOpenAILogging:
|
||||
(typeof argv.openaiLogging === 'undefined'
|
||||
? settings.model?.enableOpenAILogging
|
||||
: argv.openaiLogging) ?? false,
|
||||
openAILoggingDir:
|
||||
argv.openaiLoggingDir || settings.model?.openAILoggingDir,
|
||||
},
|
||||
modelProvidersConfig,
|
||||
generationConfigSources: resolvedCliConfig.sources,
|
||||
generationConfig: resolvedCliConfig.generationConfig,
|
||||
cliVersion: await getCliVersion(),
|
||||
webSearch: buildWebSearchConfig(
|
||||
argv,
|
||||
settings,
|
||||
settings.security?.auth?.selectedType,
|
||||
),
|
||||
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType),
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
ideMode,
|
||||
chatCompression: settings.model?.chatCompression,
|
||||
|
||||
87
packages/cli/src/config/modelProvidersScope.test.ts
Normal file
87
packages/cli/src/config/modelProvidersScope.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
48
packages/cli/src/config/modelProvidersScope.ts
Normal file
48
packages/cli/src/config/modelProvidersScope.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import { disableExtension } from './extension.js';
|
||||
|
||||
// These imports will get the versions from the vi.mock('./settings.js', ...) factory.
|
||||
import {
|
||||
getSettingsWarnings,
|
||||
loadSettings,
|
||||
USER_SETTINGS_PATH, // This IS the mocked path.
|
||||
getSystemSettingsPath,
|
||||
@@ -418,6 +419,86 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should warn about ignored legacy keys in a v2 settings file', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||
usageStatisticsEnabled: false,
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(getSettingsWarnings(settings)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"Legacy setting 'usageStatisticsEnabled' will be ignored",
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(getSettingsWarnings(settings)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("'privacy.usageStatisticsEnabled'"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn about unknown top-level keys in a v2 settings file', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||
someUnknownKey: 'value',
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(getSettingsWarnings(settings)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"Unknown setting 'someUnknownKey' will be ignored",
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not warn for valid v2 container keys', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||
model: { name: 'qwen-coder' },
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(getSettingsWarnings(settings)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should rewrite allowedTools to tools.allowed during migration', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
@@ -2179,7 +2260,7 @@ describe('Settings Loading and Merging', () => {
|
||||
disableAutoUpdate: true,
|
||||
},
|
||||
ui: {
|
||||
hideBanner: true,
|
||||
hideTips: true,
|
||||
customThemes: {
|
||||
myTheme: {},
|
||||
},
|
||||
@@ -2202,7 +2283,7 @@ describe('Settings Loading and Merging', () => {
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
disableAutoUpdate: true,
|
||||
hideBanner: true,
|
||||
hideTips: true,
|
||||
customThemes: {
|
||||
myTheme: {},
|
||||
},
|
||||
|
||||
@@ -90,13 +90,6 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
hideWindowTitle: 'ui.hideWindowTitle',
|
||||
showStatusInTitle: 'ui.showStatusInTitle',
|
||||
hideTips: 'ui.hideTips',
|
||||
hideBanner: 'ui.hideBanner',
|
||||
hideFooter: 'ui.hideFooter',
|
||||
hideCWD: 'ui.footer.hideCWD',
|
||||
hideSandboxStatus: 'ui.footer.hideSandboxStatus',
|
||||
hideModelInfo: 'ui.footer.hideModelInfo',
|
||||
hideContextSummary: 'ui.hideContextSummary',
|
||||
showMemoryUsage: 'ui.showMemoryUsage',
|
||||
showLineNumbers: 'ui.showLineNumbers',
|
||||
showCitations: 'ui.showCitations',
|
||||
ideMode: 'ide.enabled',
|
||||
@@ -344,6 +337,97 @@ const KNOWN_V2_CONTAINERS = new Set(
|
||||
Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
|
||||
);
|
||||
|
||||
function getSettingsFileKeyWarnings(
|
||||
settings: Record<string, unknown>,
|
||||
settingsFilePath: string,
|
||||
): string[] {
|
||||
const version = settings[SETTINGS_VERSION_KEY];
|
||||
if (typeof version !== 'number' || version < SETTINGS_VERSION) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const ignoredLegacyKeys = new Set<string>();
|
||||
|
||||
// Ignored legacy keys (V1 top-level keys that moved to a nested V2 path).
|
||||
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
|
||||
if (oldKey === newPath) {
|
||||
continue;
|
||||
}
|
||||
if (!(oldKey in settings)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldValue = settings[oldKey];
|
||||
|
||||
// If this key is a V2 container (like 'model') and it's already an object,
|
||||
// it's likely already in V2 format. Don't warn.
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(oldKey) &&
|
||||
typeof oldValue === 'object' &&
|
||||
oldValue !== null &&
|
||||
!Array.isArray(oldValue)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ignoredLegacyKeys.add(oldKey);
|
||||
warnings.push(
|
||||
`⚠️ Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown top-level keys.
|
||||
const schemaKeys = new Set(Object.keys(getSettingsSchema()));
|
||||
for (const key of Object.keys(settings)) {
|
||||
if (key === SETTINGS_VERSION_KEY) {
|
||||
continue;
|
||||
}
|
||||
if (ignoredLegacyKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (schemaKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
warnings.push(
|
||||
`⚠️ Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects warnings for ignored legacy and unknown settings keys.
|
||||
*
|
||||
* For `$version: 2` settings files, we do not apply implicit migrations.
|
||||
* Instead, we surface actionable, de-duplicated warnings in the terminal UI.
|
||||
*/
|
||||
export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
|
||||
const warningSet = new Set<string>();
|
||||
|
||||
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
|
||||
const settingsFile = loadedSettings.forScope(scope);
|
||||
if (settingsFile.rawJson === undefined) {
|
||||
continue; // File not present / not loaded.
|
||||
}
|
||||
const settingsObject = settingsFile.originalSettings as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
for (const warning of getSettingsFileKeyWarnings(
|
||||
settingsObject,
|
||||
settingsFile.path,
|
||||
)) {
|
||||
warningSet.add(warning);
|
||||
}
|
||||
}
|
||||
|
||||
return [...warningSet];
|
||||
}
|
||||
|
||||
export function migrateSettingsToV1(
|
||||
v2Settings: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
|
||||
@@ -157,9 +157,6 @@ describe('SettingsSchema', () => {
|
||||
|
||||
it('should have showInDialog property configured', () => {
|
||||
// Check that user-facing settings are marked for dialog display
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showMemoryUsage.showInDialog,
|
||||
).toBe(true);
|
||||
expect(getSettingsSchema().general.properties.vimMode.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -175,9 +172,6 @@ describe('SettingsSchema', () => {
|
||||
expect(getSettingsSchema().ui.properties.hideTips.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(getSettingsSchema().ui.properties.hideBanner.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().privacy.properties.usageStatisticsEnabled
|
||||
.showInDialog,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
TelemetrySettings,
|
||||
AuthType,
|
||||
ChatCompressionSettings,
|
||||
ModelProvidersConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
ApprovalMode,
|
||||
@@ -102,6 +103,19 @@ const SETTINGS_SCHEMA = {
|
||||
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: {
|
||||
type: 'object',
|
||||
label: 'General',
|
||||
@@ -202,6 +216,7 @@ const SETTINGS_SCHEMA = {
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'zh', label: '中文 (Chinese)' },
|
||||
{ value: 'ru', label: 'Русский (Russian)' },
|
||||
{ value: 'de', label: 'Deutsch (German)' },
|
||||
],
|
||||
},
|
||||
terminalBell: {
|
||||
@@ -306,82 +321,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide helpful tips in the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideBanner: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Banner',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Hide the application banner',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideContextSummary: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Context Summary',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Hide the context summary (QWEN.md, MCP servers) above the input.',
|
||||
showInDialog: true,
|
||||
},
|
||||
footer: {
|
||||
type: 'object',
|
||||
label: 'Footer',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Settings for the footer.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
hideCWD: {
|
||||
type: 'boolean',
|
||||
label: 'Hide CWD',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Hide the current working directory path in the footer.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideSandboxStatus: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Sandbox Status',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Hide the sandbox status indicator in the footer.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideModelInfo: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Model Info',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Hide the model name and context usage in the footer.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
hideFooter: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Footer',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Hide the footer from the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
showMemoryUsage: {
|
||||
type: 'boolean',
|
||||
label: 'Show Memory Usage',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Display memory usage information in the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
showLineNumbers: {
|
||||
type: 'boolean',
|
||||
label: 'Show Line Numbers',
|
||||
@@ -1277,9 +1216,3 @@ type InferSettings<T extends SettingsSchema> = {
|
||||
};
|
||||
|
||||
export type Settings = InferSettings<SettingsSchemaType>;
|
||||
|
||||
export interface FooterSettings {
|
||||
hideCWD?: boolean;
|
||||
hideSandboxStatus?: boolean;
|
||||
hideModelInfo?: boolean;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,9 @@ export async function initializeApp(
|
||||
// Auto-detect and set LLM output language on first use
|
||||
initializeLlmOutputLanguage();
|
||||
|
||||
const authType = settings.merged.security?.auth?.selectedType;
|
||||
// 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);
|
||||
|
||||
// Fallback to user select when initial authentication fails
|
||||
@@ -59,7 +61,7 @@ export async function initializeApp(
|
||||
const themeError = validateTheme(settings);
|
||||
|
||||
const shouldOpenAuthDialog =
|
||||
settings.merged.security?.auth?.selectedType === undefined || !!authError;
|
||||
!config.modelsConfig.wasAuthTypeExplicitlyProvided() || !!authError;
|
||||
|
||||
if (config.getIdeMode()) {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
|
||||
@@ -87,6 +87,15 @@ vi.mock('./config/sandboxConfig.js', () => ({
|
||||
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', () => {
|
||||
let originalEnvGeminiSandbox: string | undefined;
|
||||
let originalEnvSandbox: string | undefined;
|
||||
@@ -362,7 +371,6 @@ describe('gemini.tsx main function', () => {
|
||||
expect(inputArg).toBe('hello stream');
|
||||
|
||||
expect(validateAuthSpy).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
configStub,
|
||||
expect.any(Object),
|
||||
@@ -448,7 +456,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
promptInteractive: undefined,
|
||||
query: undefined,
|
||||
allFiles: undefined,
|
||||
showMemoryUsage: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
telemetry: undefined,
|
||||
@@ -460,6 +467,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
telemetryOutfile: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
acp: undefined,
|
||||
experimentalAcp: undefined,
|
||||
experimentalSkills: undefined,
|
||||
extensions: undefined,
|
||||
@@ -639,4 +647,37 @@ describe('startInteractiveUI', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config, AuthType } from '@qwen-code/qwen-code-core';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { InputFormat, logUserPrompt } from '@qwen-code/qwen-code-core';
|
||||
import { render } from 'ink';
|
||||
import dns from 'node:dns';
|
||||
@@ -17,7 +17,11 @@ import * as cliConfig from './config/config.js';
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import { ExtensionStorage, loadExtensions } from './config/extension.js';
|
||||
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
|
||||
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
|
||||
import {
|
||||
getSettingsWarnings,
|
||||
loadSettings,
|
||||
migrateDeprecatedSettings,
|
||||
} from './config/settings.js';
|
||||
import {
|
||||
initializeApp,
|
||||
type InitializationResult,
|
||||
@@ -183,16 +187,18 @@ export async function startInteractiveUI(
|
||||
},
|
||||
);
|
||||
|
||||
checkForUpdates()
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
})
|
||||
.catch((err) => {
|
||||
// Silently ignore update check errors.
|
||||
if (config.getDebugMode()) {
|
||||
console.error('Update check failed:', err);
|
||||
}
|
||||
});
|
||||
if (!settings.merged.general?.disableUpdateNag) {
|
||||
checkForUpdates()
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
})
|
||||
.catch((err) => {
|
||||
// Silently ignore update check errors.
|
||||
if (config.getDebugMode()) {
|
||||
console.error('Update check failed:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
registerCleanup(() => instance.unmount());
|
||||
}
|
||||
@@ -250,22 +256,20 @@ export async function main() {
|
||||
argv,
|
||||
);
|
||||
|
||||
if (
|
||||
settings.merged.security?.auth?.selectedType &&
|
||||
!settings.merged.security?.auth?.useExternal
|
||||
) {
|
||||
if (!settings.merged.security?.auth?.useExternal) {
|
||||
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
|
||||
try {
|
||||
const err = validateAuthMethod(
|
||||
settings.merged.security.auth.selectedType,
|
||||
);
|
||||
if (err) {
|
||||
throw new Error(err);
|
||||
}
|
||||
const authType = partialConfig.modelsConfig.getCurrentAuthType();
|
||||
// Fresh users may not have selected/persisted an authType yet.
|
||||
// In that case, defer auth prompting/selection to the main interactive flow.
|
||||
if (authType) {
|
||||
const err = validateAuthMethod(authType, partialConfig);
|
||||
if (err) {
|
||||
throw new Error(err);
|
||||
}
|
||||
|
||||
await partialConfig.refreshAuth(
|
||||
settings.merged.security.auth.selectedType,
|
||||
);
|
||||
await partialConfig.refreshAuth(authType);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error authenticating:', err);
|
||||
process.exit(1);
|
||||
@@ -342,6 +346,7 @@ export async function main() {
|
||||
extensionEnablementManager,
|
||||
argv,
|
||||
);
|
||||
registerCleanup(() => config.shutdown());
|
||||
|
||||
if (config.getListExtensions()) {
|
||||
console.log('Installed extensions:');
|
||||
@@ -400,12 +405,15 @@ export async function main() {
|
||||
|
||||
let input = config.getQuestion();
|
||||
const startupWarnings = [
|
||||
...(await getStartupWarnings()),
|
||||
...(await getUserStartupWarnings({
|
||||
workspaceRoot: process.cwd(),
|
||||
useRipgrep: settings.merged.tools?.useRipgrep ?? true,
|
||||
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
|
||||
})),
|
||||
...new Set([
|
||||
...(await getStartupWarnings()),
|
||||
...(await getUserStartupWarnings({
|
||||
workspaceRoot: process.cwd(),
|
||||
useRipgrep: settings.merged.tools?.useRipgrep ?? true,
|
||||
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
|
||||
})),
|
||||
...getSettingsWarnings(settings),
|
||||
]),
|
||||
];
|
||||
|
||||
// Render UI, passing necessary config values. Check that there is no command line question.
|
||||
@@ -438,8 +446,6 @@ export async function main() {
|
||||
}
|
||||
|
||||
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
||||
(argv.authType as AuthType) ||
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
settings.merged.security?.auth?.useExternal,
|
||||
config,
|
||||
settings,
|
||||
|
||||
1119
packages/cli/src/i18n/locales/de.js
Normal file
1119
packages/cli/src/i18n/locales/de.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,25 @@ export default {
|
||||
'Model Context Protocol command (from external servers)':
|
||||
'Model Context Protocol command (from external servers)',
|
||||
'Keyboard Shortcuts:': 'Keyboard Shortcuts:',
|
||||
'Toggle this help display': 'Toggle this help display',
|
||||
'Toggle shell mode': 'Toggle shell mode',
|
||||
'Open command menu': 'Open command menu',
|
||||
'Add file context': 'Add file context',
|
||||
'Accept suggestion / Autocomplete': 'Accept suggestion / Autocomplete',
|
||||
'Reverse search history': 'Reverse search history',
|
||||
'Press ? again to close': 'Press ? again to close',
|
||||
// Keyboard shortcuts panel descriptions
|
||||
'for shell mode': 'for shell mode',
|
||||
'for commands': 'for commands',
|
||||
'for file paths': 'for file paths',
|
||||
'to clear input': 'to clear input',
|
||||
'to cycle approvals': 'to cycle approvals',
|
||||
'to quit': 'to quit',
|
||||
'for newline': 'for newline',
|
||||
'to clear screen': 'to clear screen',
|
||||
'to search history': 'to search history',
|
||||
'to paste images': 'to paste images',
|
||||
'for external editor': 'for external editor',
|
||||
'Jump through words in the input': 'Jump through words in the input',
|
||||
'Close dialogs, cancel requests, or quit application':
|
||||
'Close dialogs, cancel requests, or quit application',
|
||||
@@ -46,6 +65,7 @@ export default {
|
||||
'Connecting to MCP servers... ({{connected}}/{{total}})':
|
||||
'Connecting to MCP servers... ({{connected}}/{{total}})',
|
||||
'Type your message or @path/to/file': 'Type your message or @path/to/file',
|
||||
'? for shortcuts': '? for shortcuts',
|
||||
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.":
|
||||
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.",
|
||||
'Cancel operation / Clear input (double press)':
|
||||
@@ -89,6 +109,9 @@ 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}}':
|
||||
'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',
|
||||
'change the theme': 'change the theme',
|
||||
'Select Theme': 'Select Theme',
|
||||
@@ -272,13 +295,6 @@ export default {
|
||||
'Hide Window Title': 'Hide Window Title',
|
||||
'Show Status in Title': 'Show Status in Title',
|
||||
'Hide Tips': 'Hide Tips',
|
||||
'Hide Banner': 'Hide Banner',
|
||||
'Hide Context Summary': 'Hide Context Summary',
|
||||
'Hide CWD': 'Hide CWD',
|
||||
'Hide Sandbox Status': 'Hide Sandbox Status',
|
||||
'Hide Model Info': 'Hide Model Info',
|
||||
'Hide Footer': 'Hide Footer',
|
||||
'Show Memory Usage': 'Show Memory Usage',
|
||||
'Show Line Numbers': 'Show Line Numbers',
|
||||
'Show Citations': 'Show Citations',
|
||||
'Custom Witty Phrases': 'Custom Witty Phrases',
|
||||
@@ -767,6 +783,21 @@ export default {
|
||||
'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)',
|
||||
'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}}',
|
||||
'Authenticated successfully with {{authType}} credentials.':
|
||||
@@ -788,6 +819,15 @@ export default {
|
||||
// ============================================================================
|
||||
'Select Model': 'Select Model',
|
||||
'(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 Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
|
||||
@@ -864,14 +904,23 @@ export default {
|
||||
// ============================================================================
|
||||
// Startup Tips
|
||||
// ============================================================================
|
||||
'Tips for getting started:': 'Tips for getting started:',
|
||||
'1. Ask questions, edit files, or run commands.':
|
||||
'1. Ask questions, edit files, or run commands.',
|
||||
'2. Be specific for the best results.':
|
||||
'2. Be specific for the best results.',
|
||||
'files to customize your interactions with Qwen Code.':
|
||||
'files to customize your interactions with Qwen Code.',
|
||||
'for more information.': 'for more information.',
|
||||
'Tips:': 'Tips:',
|
||||
'Use /compress when the conversation gets long to summarize history and free up context.':
|
||||
'Use /compress when the conversation gets long to summarize history and free up context.',
|
||||
'Start a fresh idea with /clear or /new; the previous session stays available in history.':
|
||||
'Start a fresh idea with /clear or /new; the previous session stays available in history.',
|
||||
'Use /bug to submit issues to the maintainers when something goes off.':
|
||||
'Use /bug to submit issues to the maintainers when something goes off.',
|
||||
'Switch auth type quickly with /auth.':
|
||||
'Switch auth type quickly with /auth.',
|
||||
'You can run any shell commands from Qwen Code using ! (e.g. !ls).':
|
||||
'You can run any shell commands from Qwen Code using ! (e.g. !ls).',
|
||||
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.':
|
||||
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.',
|
||||
'You can resume a previous conversation by running qwen --continue or qwen --resume.':
|
||||
'You can resume a previous conversation by running qwen --continue or qwen --resume.',
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
|
||||
|
||||
// ============================================================================
|
||||
// Exit Screen / Stats
|
||||
@@ -1037,7 +1086,6 @@ export default {
|
||||
'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...",
|
||||
|
||||
@@ -33,6 +33,13 @@ export default {
|
||||
'Model Context Protocol command (from external servers)':
|
||||
'Команда Model Context Protocol (из внешних серверов)',
|
||||
'Keyboard Shortcuts:': 'Горячие клавиши:',
|
||||
'Toggle this help display': 'Показать/скрыть эту справку',
|
||||
'Toggle shell mode': 'Переключить режим оболочки',
|
||||
'Open command menu': 'Открыть меню команд',
|
||||
'Add file context': 'Добавить файл в контекст',
|
||||
'Accept suggestion / Autocomplete': 'Принять подсказку / Автодополнение',
|
||||
'Reverse search history': 'Обратный поиск по истории',
|
||||
'Press ? again to close': 'Нажмите ? ещё раз, чтобы закрыть',
|
||||
'Jump through words in the input': 'Переход по словам во вводе',
|
||||
'Close dialogs, cancel requests, or quit application':
|
||||
'Закрыть диалоги, отменить запросы или выйти из приложения',
|
||||
@@ -46,6 +53,7 @@ export default {
|
||||
'Connecting to MCP servers... ({{connected}}/{{total}})':
|
||||
'Подключение к MCP-серверам... ({{connected}}/{{total}})',
|
||||
'Type your message or @path/to/file': 'Введите сообщение или @путь/к/файлу',
|
||||
'? for shortcuts': '? — горячие клавиши',
|
||||
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.":
|
||||
"Нажмите 'i' для режима ВСТАВКА и 'Esc' для ОБЫЧНОГО режима.",
|
||||
'Cancel operation / Clear input (double press)':
|
||||
@@ -60,6 +68,19 @@ export default {
|
||||
'submit a bug report': 'Отправка отчёта об ошибке',
|
||||
'About Qwen Code': 'Об Qwen Code',
|
||||
|
||||
// Keyboard shortcuts panel descriptions
|
||||
'for shell mode': 'режим оболочки',
|
||||
'for commands': 'меню команд',
|
||||
'for file paths': 'пути к файлам',
|
||||
'to clear input': 'очистить ввод',
|
||||
'to cycle approvals': 'переключить режим',
|
||||
'to quit': 'выход',
|
||||
'for newline': 'новая строка',
|
||||
'to clear screen': 'очистить экран',
|
||||
'to search history': 'поиск в истории',
|
||||
'to paste images': 'вставить изображения',
|
||||
'for external editor': 'внешний редактор',
|
||||
|
||||
// ============================================================================
|
||||
// Поля системной информации
|
||||
// ============================================================================
|
||||
@@ -89,6 +110,10 @@ export default {
|
||||
'No tools available': 'Нет доступных инструментов',
|
||||
'View or change the approval mode for tool usage':
|
||||
'Просмотр или изменение режима подтверждения для использования инструментов',
|
||||
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
|
||||
'Недопустимый режим подтверждения "{{arg}}". Допустимые режимы: {{modes}}',
|
||||
'Approval mode set to "{{mode}}"':
|
||||
'Режим подтверждения установлен на "{{mode}}"',
|
||||
'View or change the language setting':
|
||||
'Просмотр или изменение настроек языка',
|
||||
'change the theme': 'Изменение темы',
|
||||
@@ -274,13 +299,6 @@ export default {
|
||||
'Hide Window Title': 'Скрыть заголовок окна',
|
||||
'Show Status in Title': 'Показывать статус в заголовке',
|
||||
'Hide Tips': 'Скрыть подсказки',
|
||||
'Hide Banner': 'Скрыть баннер',
|
||||
'Hide Context Summary': 'Скрыть сводку контекста',
|
||||
'Hide CWD': 'Скрыть текущую директорию',
|
||||
'Hide Sandbox Status': 'Скрыть статус песочницы',
|
||||
'Hide Model Info': 'Скрыть информацию о модели',
|
||||
'Hide Footer': 'Скрыть нижний колонтитул',
|
||||
'Show Memory Usage': 'Показывать использование памяти',
|
||||
'Show Line Numbers': 'Показывать номера строк',
|
||||
'Show Citations': 'Показывать цитаты',
|
||||
'Custom Witty Phrases': 'Пользовательские остроумные фразы',
|
||||
@@ -782,6 +800,21 @@ export default {
|
||||
'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.',
|
||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||
'Ожидание авторизации... (Нажмите ESC или CTRL+C для отмены)',
|
||||
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.':
|
||||
'Отсутствует 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}}':
|
||||
'Не удалось авторизоваться. Сообщение: {{message}}',
|
||||
'Authenticated successfully with {{authType}} credentials.':
|
||||
@@ -803,6 +836,15 @@ export default {
|
||||
// ============================================================================
|
||||
'Select Model': 'Выбрать модель',
|
||||
'(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)':
|
||||
'Последняя модель 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)':
|
||||
@@ -1056,7 +1098,6 @@ export default {
|
||||
'Провожу настройку методом тыка...',
|
||||
'Ищем, какой стороной вставлять флешку...',
|
||||
'Следим, чтобы волшебный дым не вышел из проводов...',
|
||||
'Переписываем всё на Rust без особой причины...',
|
||||
'Пытаемся выйти из Vim...',
|
||||
'Раскручиваем колесо для хомяка...',
|
||||
'Это не баг, а фича...',
|
||||
|
||||
@@ -32,6 +32,25 @@ export default {
|
||||
'Model Context Protocol command (from external servers)':
|
||||
'模型上下文协议命令(来自外部服务器)',
|
||||
'Keyboard Shortcuts:': '键盘快捷键:',
|
||||
'Toggle this help display': '切换此帮助显示',
|
||||
'Toggle shell mode': '切换命令行模式',
|
||||
'Open command menu': '打开命令菜单',
|
||||
'Add file context': '添加文件上下文',
|
||||
'Accept suggestion / Autocomplete': '接受建议 / 自动补全',
|
||||
'Reverse search history': '反向搜索历史',
|
||||
'Press ? again to close': '再次按 ? 关闭',
|
||||
// Keyboard shortcuts panel descriptions
|
||||
'for shell mode': '命令行模式',
|
||||
'for commands': '命令菜单',
|
||||
'for file paths': '文件路径',
|
||||
'to clear input': '清空输入',
|
||||
'to cycle approvals': '切换审批模式',
|
||||
'to quit': '退出',
|
||||
'for newline': '换行',
|
||||
'to clear screen': '清屏',
|
||||
'to search history': '搜索历史',
|
||||
'to paste images': '粘贴图片',
|
||||
'for external editor': '外部编辑器',
|
||||
'Jump through words in the input': '在输入中按单词跳转',
|
||||
'Close dialogs, cancel requests, or quit application':
|
||||
'关闭对话框、取消请求或退出应用程序',
|
||||
@@ -45,6 +64,7 @@ export default {
|
||||
'Connecting to MCP servers... ({{connected}}/{{total}})':
|
||||
'正在连接到 MCP 服务器... ({{connected}}/{{total}})',
|
||||
'Type your message or @path/to/file': '输入您的消息或 @ 文件路径',
|
||||
'? for shortcuts': '按 ? 查看快捷键',
|
||||
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.":
|
||||
"按 'i' 进入插入模式,按 'Esc' 进入普通模式",
|
||||
'Cancel operation / Clear input (double press)':
|
||||
@@ -88,6 +108,9 @@ export default {
|
||||
'No tools available': '没有可用工具',
|
||||
'View or change the approval mode for tool usage':
|
||||
'查看或更改工具使用的审批模式',
|
||||
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
|
||||
'无效的审批模式 "{{arg}}"。有效模式:{{modes}}',
|
||||
'Approval mode set to "{{mode}}"': '审批模式已设置为 "{{mode}}"',
|
||||
'View or change the language setting': '查看或更改语言设置',
|
||||
'change the theme': '更改主题',
|
||||
'Select Theme': '选择主题',
|
||||
@@ -263,13 +286,6 @@ export default {
|
||||
'Hide Window Title': '隐藏窗口标题',
|
||||
'Show Status in Title': '在标题中显示状态',
|
||||
'Hide Tips': '隐藏提示',
|
||||
'Hide Banner': '隐藏横幅',
|
||||
'Hide Context Summary': '隐藏上下文摘要',
|
||||
'Hide CWD': '隐藏当前工作目录',
|
||||
'Hide Sandbox Status': '隐藏沙箱状态',
|
||||
'Hide Model Info': '隐藏模型信息',
|
||||
'Hide Footer': '隐藏页脚',
|
||||
'Show Memory Usage': '显示内存使用',
|
||||
'Show Line Numbers': '显示行号',
|
||||
'Show Citations': '显示引用',
|
||||
'Custom Witty Phrases': '自定义诙谐短语',
|
||||
@@ -725,6 +741,21 @@ export default {
|
||||
'Authentication timed out. Please try again.': '认证超时。请重试。',
|
||||
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
|
||||
'正在等待认证...(按 ESC 或 CTRL+C 取消)',
|
||||
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.':
|
||||
'缺少 OpenAI 兼容认证的 API 密钥。请设置 settings.security.auth.apiKey 或设置 {{envKeyHint}} 环境变量。',
|
||||
'{{envKeyHint}} environment variable not found.':
|
||||
'未找到 {{envKeyHint}} 环境变量。',
|
||||
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.':
|
||||
'未找到 {{envKeyHint}} 环境变量。请在 .env 文件或系统环境变量中进行设置。',
|
||||
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.':
|
||||
'未找到 {{envKeyHint}} 环境变量(或设置 settings.security.auth.apiKey)。请在 .env 文件或系统环境变量中进行设置。',
|
||||
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.':
|
||||
'缺少 OpenAI 兼容认证的 API 密钥。请设置 {{envKeyHint}} 环境变量。',
|
||||
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.':
|
||||
'Anthropic 提供商缺少必需的 baseUrl,请在 modelProviders[].baseUrl 中配置。',
|
||||
'ANTHROPIC_BASE_URL environment variable not found.':
|
||||
'未找到 ANTHROPIC_BASE_URL 环境变量。',
|
||||
'Invalid auth method selected.': '选择了无效的认证方式。',
|
||||
'Failed to authenticate. Message: {{message}}': '认证失败。消息:{{message}}',
|
||||
'Authenticated successfully with {{authType}} credentials.':
|
||||
'使用 {{authType}} 凭据成功认证。',
|
||||
@@ -744,6 +775,15 @@ export default {
|
||||
// ============================================================================
|
||||
'Select Model': '选择模型',
|
||||
'(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)':
|
||||
'来自阿里云 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)':
|
||||
@@ -818,13 +858,22 @@ export default {
|
||||
// ============================================================================
|
||||
// Startup Tips
|
||||
// ============================================================================
|
||||
'Tips for getting started:': '入门提示:',
|
||||
'1. Ask questions, edit files, or run commands.':
|
||||
'1. 提问、编辑文件或运行命令',
|
||||
'2. Be specific for the best results.': '2. 具体描述以获得最佳结果',
|
||||
'files to customize your interactions with Qwen Code.':
|
||||
'文件以自定义您与 Qwen Code 的交互',
|
||||
'for more information.': '获取更多信息',
|
||||
'Tips:': '提示:',
|
||||
'Use /compress when the conversation gets long to summarize history and free up context.':
|
||||
'对话变长时用 /compress,总结历史并释放上下文。',
|
||||
'Start a fresh idea with /clear or /new; the previous session stays available in history.':
|
||||
'用 /clear 或 /new 开启新思路;之前的会话会保留在历史记录中。',
|
||||
'Use /bug to submit issues to the maintainers when something goes off.':
|
||||
'遇到问题时,用 /bug 将问题提交给维护者。',
|
||||
'Switch auth type quickly with /auth.': '用 /auth 快速切换认证方式。',
|
||||
'You can run any shell commands from Qwen Code using ! (e.g. !ls).':
|
||||
'在 Qwen Code 中使用 ! 可运行任意 shell 命令(例如 !ls)。',
|
||||
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.':
|
||||
'输入 / 打开命令弹窗;按 Tab 自动补全斜杠命令和保存的提示词。',
|
||||
'You can resume a previous conversation by running qwen --continue or qwen --resume.':
|
||||
'运行 qwen --continue 或 qwen --resume 可继续之前的会话。',
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
|
||||
'按 Shift+Tab 或输入 /approval-mode 可快速切换权限模式。',
|
||||
|
||||
// ============================================================================
|
||||
// Exit Screen / Stats
|
||||
|
||||
@@ -298,7 +298,9 @@ describe('runNonInteractive', () => {
|
||||
mockConfig,
|
||||
expect.objectContaining({ name: 'testTool' }),
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
outputUpdateHandler: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
// Verify first call has isContinuation: false
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
@@ -771,6 +773,52 @@ describe('runNonInteractive', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle API errors in text mode and exit with error code', async () => {
|
||||
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.TEXT);
|
||||
setupMetricsMock();
|
||||
|
||||
// Simulate an API error event (like 401 unauthorized)
|
||||
const apiErrorEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Error,
|
||||
value: {
|
||||
error: {
|
||||
message: '401 Incorrect API key provided',
|
||||
status: 401,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents([apiErrorEvent]),
|
||||
);
|
||||
|
||||
let thrownError: Error | null = null;
|
||||
try {
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'Test input',
|
||||
'prompt-id-api-error',
|
||||
);
|
||||
// Should not reach here
|
||||
expect.fail('Expected error to be thrown');
|
||||
} catch (error) {
|
||||
thrownError = error as Error;
|
||||
}
|
||||
|
||||
// Should throw with the API error message
|
||||
expect(thrownError).toBeTruthy();
|
||||
expect(thrownError?.message).toContain('401');
|
||||
expect(thrownError?.message).toContain('Incorrect API key provided');
|
||||
|
||||
// Verify error was written to stderr
|
||||
expect(processStderrSpy).toHaveBeenCalled();
|
||||
const stderrCalls = processStderrSpy.mock.calls;
|
||||
const errorOutput = stderrCalls.map((call) => call[0]).join('');
|
||||
expect(errorOutput).toContain('401');
|
||||
expect(errorOutput).toContain('Incorrect API key provided');
|
||||
});
|
||||
|
||||
it('should handle FatalInputError with custom exit code in JSON format', async () => {
|
||||
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON);
|
||||
setupMetricsMock();
|
||||
@@ -1777,4 +1825,84 @@ describe('runNonInteractive', () => {
|
||||
{ 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
Config,
|
||||
ToolCallRequestInfo,
|
||||
ToolResultDisplay,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
@@ -308,6 +312,8 @@ export async function runNonInteractive(
|
||||
config.getContentGeneratorConfig()?.authType,
|
||||
);
|
||||
process.stderr.write(`${errorText}\n`);
|
||||
// Throw error to exit with non-zero code
|
||||
throw new Error(errorText);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,7 +339,7 @@ export async function runNonInteractive(
|
||||
? options.controlService.permission.getToolCallUpdateCallback()
|
||||
: undefined;
|
||||
|
||||
// Only pass outputUpdateHandler for Task tool
|
||||
// Create output handler for Task tool (for subagent execution)
|
||||
const isTaskTool = finalRequestInfo.name === 'task';
|
||||
const taskToolProgress = isTaskTool
|
||||
? createTaskToolProgressHandler(
|
||||
@@ -343,20 +349,41 @@ export async function runNonInteractive(
|
||||
)
|
||||
: undefined;
|
||||
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(
|
||||
config,
|
||||
finalRequestInfo,
|
||||
abortController.signal,
|
||||
isTaskTool && taskToolProgressHandler
|
||||
outputUpdateHandler || toolCallUpdateCallback
|
||||
? {
|
||||
outputUpdateHandler: taskToolProgressHandler,
|
||||
onToolCallsUpdate: toolCallUpdateCallback,
|
||||
}
|
||||
: toolCallUpdateCallback
|
||||
? {
|
||||
...(outputUpdateHandler && { outputUpdateHandler }),
|
||||
...(toolCallUpdateCallback && {
|
||||
onToolCallsUpdate: toolCallUpdateCallback,
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Note: In JSON mode, subagent messages are automatically added to the main
|
||||
|
||||
@@ -31,6 +31,7 @@ import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { resumeCommand } from '../ui/commands/resumeCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { summaryCommand } from '../ui/commands/summaryCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
@@ -78,6 +79,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
resumeCommand,
|
||||
...(this.config?.getExperimentalSkills?.() ? [skillsCommand] : []),
|
||||
statsCommand,
|
||||
summaryCommand,
|
||||
themeCommand,
|
||||
|
||||
@@ -72,6 +72,7 @@ describe('ShellProcessor', () => {
|
||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||
getAllowedTools: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
|
||||
context = createMockCommandContext({
|
||||
@@ -196,6 +197,35 @@ 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 () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt: PromptPipelineContent = createPromptPipelineContent(
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
import {
|
||||
ApprovalMode,
|
||||
checkCommandPermissions,
|
||||
doesToolInvocationMatch,
|
||||
escapeShellArg,
|
||||
getShellConfiguration,
|
||||
ShellExecutionService,
|
||||
flatMapTextParts,
|
||||
} 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 { IPromptProcessor, PromptPipelineContent } from './types.js';
|
||||
@@ -124,6 +126,15 @@ export class ShellProcessor implements IPromptProcessor {
|
||||
// Security check on the final, escaped command string.
|
||||
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
|
||||
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 (isHardDenial) {
|
||||
@@ -132,10 +143,17 @@ export class ShellProcessor implements IPromptProcessor {
|
||||
);
|
||||
}
|
||||
|
||||
// If not a hard denial, respect YOLO mode and auto-approve.
|
||||
if (config.getApprovalMode() !== ApprovalMode.YOLO) {
|
||||
disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
|
||||
// If the command is allowed by settings, skip confirmation.
|
||||
if (isAllowedBySettings) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If not a hard denial, respect YOLO mode and auto-approve.
|
||||
if (config.getApprovalMode() === ApprovalMode.YOLO) {
|
||||
continue;
|
||||
}
|
||||
|
||||
disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import type React from 'react';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { LoadedSettings } from '../config/settings.js';
|
||||
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
|
||||
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
|
||||
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
|
||||
import { ConfigContext } from '../ui/contexts/ConfigContext.js';
|
||||
|
||||
const mockSettings = new LoadedSettings(
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
@@ -22,14 +24,24 @@ const mockSettings = new LoadedSettings(
|
||||
|
||||
export const renderWithProviders = (
|
||||
component: React.ReactElement,
|
||||
{ shellFocus = true, settings = mockSettings } = {},
|
||||
{
|
||||
shellFocus = true,
|
||||
settings = mockSettings,
|
||||
config = undefined,
|
||||
}: {
|
||||
shellFocus?: boolean;
|
||||
settings?: LoadedSettings;
|
||||
config?: Config;
|
||||
} = {},
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<KeypressProvider kittyProtocolEnabled={true}>
|
||||
{component}
|
||||
</KeypressProvider>
|
||||
</ShellFocusContext.Provider>
|
||||
<ConfigContext.Provider value={config}>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<KeypressProvider kittyProtocolEnabled={true}>
|
||||
{component}
|
||||
</KeypressProvider>
|
||||
</ShellFocusContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
@@ -5,34 +5,15 @@
|
||||
*/
|
||||
|
||||
import { useIsScreenReaderEnabled } from 'ink';
|
||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
import { lerp } from '../utils/math.js';
|
||||
import { useUIState } from './contexts/UIStateContext.js';
|
||||
import { StreamingContext } from './contexts/StreamingContext.js';
|
||||
import { QuittingDisplay } from './components/QuittingDisplay.js';
|
||||
import { ScreenReaderAppLayout } from './layouts/ScreenReaderAppLayout.js';
|
||||
import { DefaultAppLayout } from './layouts/DefaultAppLayout.js';
|
||||
|
||||
const getContainerWidth = (terminalWidth: number): string => {
|
||||
if (terminalWidth <= 80) {
|
||||
return '98%';
|
||||
}
|
||||
if (terminalWidth >= 132) {
|
||||
return '90%';
|
||||
}
|
||||
|
||||
// Linearly interpolate between 80 columns (98%) and 132 columns (90%).
|
||||
const t = (terminalWidth - 80) / (132 - 80);
|
||||
const percentage = lerp(98, 90, t);
|
||||
|
||||
return `${Math.round(percentage)}%`;
|
||||
};
|
||||
|
||||
export const App = () => {
|
||||
const uiState = useUIState();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const { columns } = useTerminalSize();
|
||||
const containerWidth = getContainerWidth(columns);
|
||||
|
||||
if (uiState.quittingMessages) {
|
||||
return <QuittingDisplay />;
|
||||
@@ -40,11 +21,7 @@ export const App = () => {
|
||||
|
||||
return (
|
||||
<StreamingContext.Provider value={uiState.streamingState}>
|
||||
{isScreenReaderEnabled ? (
|
||||
<ScreenReaderAppLayout />
|
||||
) : (
|
||||
<DefaultAppLayout width={containerWidth} />
|
||||
)}
|
||||
{isScreenReaderEnabled ? <ScreenReaderAppLayout /> : <DefaultAppLayout />}
|
||||
</StreamingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -294,10 +294,7 @@ describe('AppContainer State Management', () => {
|
||||
// Mock LoadedSettings
|
||||
mockSettings = {
|
||||
merged: {
|
||||
hideBanner: false,
|
||||
hideFooter: false,
|
||||
hideTips: false,
|
||||
showMemoryUsage: false,
|
||||
theme: 'default',
|
||||
ui: {
|
||||
showStatusInTitle: false,
|
||||
@@ -445,10 +442,7 @@ describe('AppContainer State Management', () => {
|
||||
it('handles settings with all display options disabled', () => {
|
||||
const settingsAllHidden = {
|
||||
merged: {
|
||||
hideBanner: true,
|
||||
hideFooter: true,
|
||||
hideTips: true,
|
||||
showMemoryUsage: false,
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
@@ -463,28 +457,6 @@ describe('AppContainer State Management', () => {
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles settings with memory usage enabled', () => {
|
||||
const settingsWithMemory = {
|
||||
merged: {
|
||||
hideBanner: false,
|
||||
hideFooter: false,
|
||||
hideTips: false,
|
||||
showMemoryUsage: true,
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={settingsWithMemory}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Version Handling', () => {
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
type Config,
|
||||
type IdeInfo,
|
||||
type IdeContext,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
IdeClient,
|
||||
ideContextStore,
|
||||
getErrorMessage,
|
||||
@@ -180,15 +179,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
[],
|
||||
);
|
||||
|
||||
// Helper to determine the effective model, considering the fallback state.
|
||||
const getEffectiveModel = useCallback(() => {
|
||||
if (config.isInFallbackMode()) {
|
||||
return DEFAULT_GEMINI_FLASH_MODEL;
|
||||
}
|
||||
return config.getModel();
|
||||
}, [config]);
|
||||
// Helper to determine the current model (polled, since Config has no model-change event).
|
||||
const getCurrentModel = useCallback(() => config.getModel(), [config]);
|
||||
|
||||
const [currentModel, setCurrentModel] = useState(getEffectiveModel());
|
||||
const [currentModel, setCurrentModel] = useState(getCurrentModel());
|
||||
|
||||
const [isConfigInitialized, setConfigInitialized] = useState(false);
|
||||
|
||||
@@ -241,12 +235,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
[historyManager.addItem],
|
||||
);
|
||||
|
||||
// Watch for model changes (e.g., from Flash fallback)
|
||||
// Watch for model changes (e.g., user switches model via /model)
|
||||
useEffect(() => {
|
||||
const checkModelChange = () => {
|
||||
const effectiveModel = getEffectiveModel();
|
||||
if (effectiveModel !== currentModel) {
|
||||
setCurrentModel(effectiveModel);
|
||||
const model = getCurrentModel();
|
||||
if (model !== currentModel) {
|
||||
setCurrentModel(model);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -254,7 +248,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const interval = setInterval(checkModelChange, 1000); // Check every second
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [config, currentModel, getEffectiveModel]);
|
||||
}, [config, currentModel, getCurrentModel]);
|
||||
|
||||
const {
|
||||
consoleMessages,
|
||||
@@ -277,7 +271,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
calculatePromptWidths(terminalWidth);
|
||||
return { inputWidth, suggestionsWidth };
|
||||
}, [terminalWidth]);
|
||||
const mainAreaWidth = Math.floor(terminalWidth * 0.9);
|
||||
// Uniform width for bordered box components: accounts for margins and caps at 100
|
||||
const mainAreaWidth = Math.min(terminalWidth - 4, 100);
|
||||
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
|
||||
|
||||
const isValidPath = useCallback((filePath: string): boolean => {
|
||||
@@ -376,37 +371,36 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Check for enforced auth type mismatch
|
||||
useEffect(() => {
|
||||
// Check for initialization error first
|
||||
const currentAuthType = config.modelsConfig.getCurrentAuthType();
|
||||
|
||||
if (
|
||||
settings.merged.security?.auth?.enforcedType &&
|
||||
settings.merged.security?.auth.selectedType &&
|
||||
settings.merged.security?.auth.enforcedType !==
|
||||
settings.merged.security?.auth.selectedType
|
||||
currentAuthType &&
|
||||
settings.merged.security?.auth.enforcedType !== currentAuthType
|
||||
) {
|
||||
onAuthError(
|
||||
t(
|
||||
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.',
|
||||
{
|
||||
enforcedType: settings.merged.security?.auth.enforcedType,
|
||||
currentType: settings.merged.security?.auth.selectedType,
|
||||
enforcedType: String(settings.merged.security?.auth.enforcedType),
|
||||
currentType: String(currentAuthType),
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
settings.merged.security?.auth?.selectedType &&
|
||||
!settings.merged.security?.auth?.useExternal
|
||||
) {
|
||||
const error = validateAuthMethod(
|
||||
settings.merged.security.auth.selectedType,
|
||||
);
|
||||
if (error) {
|
||||
onAuthError(error);
|
||||
} else if (!settings.merged.security?.auth?.useExternal) {
|
||||
// If no authType is selected yet, allow the auth UI flow to prompt the user.
|
||||
// Only validate credentials once a concrete authType exists.
|
||||
if (currentAuthType) {
|
||||
const error = validateAuthMethod(currentAuthType, config);
|
||||
if (error) {
|
||||
onAuthError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
settings.merged.security?.auth?.enforcedType,
|
||||
settings.merged.security?.auth?.useExternal,
|
||||
config,
|
||||
onAuthError,
|
||||
]);
|
||||
|
||||
@@ -925,7 +919,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const handleIdePromptComplete = useCallback(
|
||||
(result: IdeIntegrationNudgeResult) => {
|
||||
if (result.userSelection === 'yes') {
|
||||
handleSlashCommand('/ide install');
|
||||
// Check whether the extension has been pre-installed
|
||||
if (result.isExtensionPreInstalled) {
|
||||
handleSlashCommand('/ide enable');
|
||||
} else {
|
||||
handleSlashCommand('/ide install');
|
||||
}
|
||||
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||
} else if (result.userSelection === 'dismiss') {
|
||||
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||
|
||||
@@ -38,6 +38,7 @@ export function IdeIntegrationNudge({
|
||||
);
|
||||
|
||||
const { displayName: ideName } = ide;
|
||||
const isInSandbox = !!process.env['SANDBOX'];
|
||||
// Assume extension is already installed if the env variables are set.
|
||||
const isExtensionPreInstalled =
|
||||
!!process.env['QWEN_CODE_IDE_SERVER_PORT'] &&
|
||||
@@ -70,13 +71,15 @@ export function IdeIntegrationNudge({
|
||||
},
|
||||
];
|
||||
|
||||
const installText = isExtensionPreInstalled
|
||||
? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${
|
||||
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'
|
||||
}.`;
|
||||
const installText = isInSandbox
|
||||
? `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.`
|
||||
: isExtensionPreInstalled
|
||||
? `If you select Yes, the CLI will connect to your ${
|
||||
ideName ?? 'editor'
|
||||
} and have access to your open files and display diffs directly.`
|
||||
: `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 (
|
||||
<Box
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { AuthDialog } from './AuthDialog.js';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||
@@ -43,17 +44,24 @@ const renderAuthDialog = (
|
||||
settings: LoadedSettings,
|
||||
uiStateOverrides: Partial<UIState> = {},
|
||||
uiActionsOverrides: Partial<UIActions> = {},
|
||||
configAuthType: AuthType | undefined = undefined,
|
||||
configApiKey: string | undefined = undefined,
|
||||
) => {
|
||||
const uiState = createMockUIState(uiStateOverrides);
|
||||
const uiActions = createMockUIActions(uiActionsOverrides);
|
||||
|
||||
const mockConfig = {
|
||||
getAuthType: vi.fn(() => configAuthType),
|
||||
getContentGeneratorConfig: vi.fn(() => ({ apiKey: configApiKey })),
|
||||
} as unknown as Config;
|
||||
|
||||
return renderWithProviders(
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<AuthDialog />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>,
|
||||
{ settings },
|
||||
{ settings, config: mockConfig },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -421,6 +429,7 @@ describe('AuthDialog', () => {
|
||||
settings,
|
||||
{},
|
||||
{ handleAuthSelect },
|
||||
undefined, // config.getAuthType() returns undefined
|
||||
);
|
||||
await wait();
|
||||
|
||||
@@ -475,6 +484,7 @@ describe('AuthDialog', () => {
|
||||
settings,
|
||||
{ authError: 'Initial error' },
|
||||
{ handleAuthSelect },
|
||||
undefined, // config.getAuthType() returns undefined
|
||||
);
|
||||
await wait();
|
||||
|
||||
@@ -528,6 +538,7 @@ describe('AuthDialog', () => {
|
||||
settings,
|
||||
{},
|
||||
{ handleAuthSelect },
|
||||
AuthType.USE_OPENAI, // config.getAuthType() returns USE_OPENAI
|
||||
);
|
||||
await wait();
|
||||
|
||||
@@ -536,7 +547,7 @@ describe('AuthDialog', () => {
|
||||
await wait();
|
||||
|
||||
// Should call handleAuthSelect with undefined to exit
|
||||
expect(handleAuthSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
|
||||
expect(handleAuthSelect).toHaveBeenCalledWith(undefined);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,13 +8,12 @@ import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { Box, Text } from 'ink';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
function parseDefaultAuthType(
|
||||
@@ -32,7 +31,7 @@ function parseDefaultAuthType(
|
||||
export function AuthDialog(): React.JSX.Element {
|
||||
const { pendingAuthType, authError } = useUIState();
|
||||
const { handleAuthSelect: onAuthSelect } = useUIActions();
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
@@ -58,9 +57,10 @@ export function AuthDialog(): React.JSX.Element {
|
||||
return item.value === pendingAuthType;
|
||||
}
|
||||
|
||||
// Priority 2: settings.merged.security?.auth?.selectedType
|
||||
if (settings.merged.security?.auth?.selectedType) {
|
||||
return item.value === settings.merged.security?.auth?.selectedType;
|
||||
// Priority 2: config.getAuthType() - the source of truth
|
||||
const currentAuthType = config.getAuthType();
|
||||
if (currentAuthType) {
|
||||
return item.value === currentAuthType;
|
||||
}
|
||||
|
||||
// Priority 3: QWEN_DEFAULT_AUTH_TYPE env var
|
||||
@@ -76,7 +76,7 @@ export function AuthDialog(): React.JSX.Element {
|
||||
}),
|
||||
);
|
||||
|
||||
const hasApiKey = Boolean(settings.merged.security?.auth?.apiKey);
|
||||
const hasApiKey = Boolean(config.getContentGeneratorConfig()?.apiKey);
|
||||
const currentSelectedAuthType =
|
||||
selectedIndex !== null
|
||||
? items[selectedIndex]?.value
|
||||
@@ -84,7 +84,7 @@ export function AuthDialog(): React.JSX.Element {
|
||||
|
||||
const handleAuthSelect = async (authMethod: AuthType) => {
|
||||
setErrorMessage(null);
|
||||
await onAuthSelect(authMethod, SettingScope.User);
|
||||
await onAuthSelect(authMethod);
|
||||
};
|
||||
|
||||
const handleHighlight = (authMethod: AuthType) => {
|
||||
@@ -100,7 +100,7 @@ export function AuthDialog(): React.JSX.Element {
|
||||
if (errorMessage) {
|
||||
return;
|
||||
}
|
||||
if (settings.merged.security?.auth?.selectedType === undefined) {
|
||||
if (config.getAuthType() === undefined) {
|
||||
// Prevent exiting if no auth method is set
|
||||
setErrorMessage(
|
||||
t(
|
||||
@@ -109,7 +109,7 @@ export function AuthDialog(): React.JSX.Element {
|
||||
);
|
||||
return;
|
||||
}
|
||||
onAuthSelect(undefined, SettingScope.User);
|
||||
onAuthSelect(undefined);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthEvent,
|
||||
AuthType,
|
||||
clearCachedCredentialFile,
|
||||
getErrorMessage,
|
||||
logAuth,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
||||
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||
import { useQwenAuth } from '../hooks/useQwenAuth.js';
|
||||
import { AuthState, MessageType } from '../types.js';
|
||||
@@ -27,8 +27,7 @@ export const useAuthCommand = (
|
||||
config: Config,
|
||||
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
|
||||
) => {
|
||||
const unAuthenticated =
|
||||
settings.merged.security?.auth?.selectedType === undefined;
|
||||
const unAuthenticated = config.getAuthType() === undefined;
|
||||
|
||||
const [authState, setAuthState] = useState<AuthState>(
|
||||
unAuthenticated ? AuthState.Updating : AuthState.Unauthenticated,
|
||||
@@ -81,35 +80,46 @@ export const useAuthCommand = (
|
||||
);
|
||||
|
||||
const handleAuthSuccess = useCallback(
|
||||
async (
|
||||
authType: AuthType,
|
||||
scope: SettingScope,
|
||||
credentials?: OpenAICredentials,
|
||||
) => {
|
||||
async (authType: AuthType, credentials?: OpenAICredentials) => {
|
||||
try {
|
||||
settings.setValue(scope, 'security.auth.selectedType', authType);
|
||||
const authTypeScope = getPersistScopeForModelSelection(settings);
|
||||
|
||||
// Persist authType
|
||||
settings.setValue(
|
||||
authTypeScope,
|
||||
'security.auth.selectedType',
|
||||
authType,
|
||||
);
|
||||
|
||||
// Persist model from ContentGenerator config (handles fallback cases)
|
||||
// This ensures that when syncAfterAuthRefresh falls back to default model,
|
||||
// it gets persisted to settings.json
|
||||
const contentGeneratorConfig = config.getContentGeneratorConfig();
|
||||
if (contentGeneratorConfig?.model) {
|
||||
settings.setValue(
|
||||
authTypeScope,
|
||||
'model.name',
|
||||
contentGeneratorConfig.model,
|
||||
);
|
||||
}
|
||||
|
||||
// Only update credentials if not switching to QWEN_OAUTH,
|
||||
// so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
|
||||
if (authType !== AuthType.QWEN_OAUTH && credentials) {
|
||||
if (credentials?.apiKey != null) {
|
||||
settings.setValue(
|
||||
scope,
|
||||
authTypeScope,
|
||||
'security.auth.apiKey',
|
||||
credentials.apiKey,
|
||||
);
|
||||
}
|
||||
if (credentials?.baseUrl != null) {
|
||||
settings.setValue(
|
||||
scope,
|
||||
authTypeScope,
|
||||
'security.auth.baseUrl',
|
||||
credentials.baseUrl,
|
||||
);
|
||||
}
|
||||
if (credentials?.model != null) {
|
||||
settings.setValue(scope, 'model.name', credentials.model);
|
||||
}
|
||||
await clearCachedCredentialFile();
|
||||
}
|
||||
} catch (error) {
|
||||
handleAuthFailure(error);
|
||||
@@ -141,14 +151,10 @@ export const useAuthCommand = (
|
||||
);
|
||||
|
||||
const performAuth = useCallback(
|
||||
async (
|
||||
authType: AuthType,
|
||||
scope: SettingScope,
|
||||
credentials?: OpenAICredentials,
|
||||
) => {
|
||||
async (authType: AuthType, credentials?: OpenAICredentials) => {
|
||||
try {
|
||||
await config.refreshAuth(authType);
|
||||
handleAuthSuccess(authType, scope, credentials);
|
||||
handleAuthSuccess(authType, credentials);
|
||||
} catch (e) {
|
||||
handleAuthFailure(e);
|
||||
}
|
||||
@@ -156,18 +162,51 @@ export const useAuthCommand = (
|
||||
[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(
|
||||
async (
|
||||
authType: AuthType | undefined,
|
||||
scope: SettingScope,
|
||||
credentials?: OpenAICredentials,
|
||||
) => {
|
||||
async (authType: AuthType | undefined, credentials?: OpenAICredentials) => {
|
||||
if (!authType) {
|
||||
setIsAuthDialogOpen(false);
|
||||
setAuthError(null);
|
||||
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);
|
||||
setAuthError(null);
|
||||
setIsAuthDialogOpen(false);
|
||||
@@ -180,14 +219,14 @@ export const useAuthCommand = (
|
||||
baseUrl: credentials.baseUrl,
|
||||
model: credentials.model,
|
||||
});
|
||||
await performAuth(authType, scope, credentials);
|
||||
await performAuth(authType, credentials);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await performAuth(authType, scope);
|
||||
await performAuth(authType);
|
||||
},
|
||||
[config, performAuth],
|
||||
[config, performAuth, isProviderManagedModel, onAuthError],
|
||||
);
|
||||
|
||||
const openAuthDialog = useCallback(() => {
|
||||
|
||||
@@ -4,31 +4,28 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { approvalModeCommand } from './approvalModeCommand.js';
|
||||
import {
|
||||
type CommandContext,
|
||||
CommandKind,
|
||||
type OpenDialogActionReturn,
|
||||
type MessageActionReturn,
|
||||
} from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
describe('approvalModeCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
let mockSetApprovalMode: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSetApprovalMode = vi.fn();
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getApprovalMode: () => 'default',
|
||||
setApprovalMode: () => {},
|
||||
setApprovalMode: mockSetApprovalMode,
|
||||
},
|
||||
settings: {
|
||||
merged: {},
|
||||
setValue: () => {},
|
||||
forScope: () => ({}),
|
||||
} as unknown as LoadedSettings,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -41,7 +38,7 @@ describe('approvalModeCommand', () => {
|
||||
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
it('should open approval mode dialog when invoked', async () => {
|
||||
it('should open approval mode dialog when invoked without arguments', async () => {
|
||||
const result = (await approvalModeCommand.action?.(
|
||||
mockContext,
|
||||
'',
|
||||
@@ -51,16 +48,123 @@ describe('approvalModeCommand', () => {
|
||||
expect(result.dialog).toBe('approval-mode');
|
||||
});
|
||||
|
||||
it('should open approval mode dialog with arguments (ignored)', async () => {
|
||||
it('should open approval mode dialog when invoked with whitespace only', async () => {
|
||||
const result = (await approvalModeCommand.action?.(
|
||||
mockContext,
|
||||
'some arguments',
|
||||
' ',
|
||||
)) as OpenDialogActionReturn;
|
||||
|
||||
expect(result.type).toBe('dialog');
|
||||
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', () => {
|
||||
expect(approvalModeCommand.subCommands).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -8,9 +8,25 @@ import type {
|
||||
SlashCommand,
|
||||
CommandContext,
|
||||
OpenDialogActionReturn,
|
||||
MessageActionReturn,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.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 = {
|
||||
name: 'approval-mode',
|
||||
@@ -19,10 +35,49 @@ export const approvalModeCommand: SlashCommand = {
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
_context: CommandContext,
|
||||
_args: string,
|
||||
): Promise<OpenDialogActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'approval-mode',
|
||||
}),
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<OpenDialogActionReturn | MessageActionReturn> => {
|
||||
const mode = parseApprovalModeArg(args);
|
||||
|
||||
// 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 }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -191,11 +191,23 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
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) {
|
||||
const ideName = ideClient.getDetectedIdeDisplayName();
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'error',
|
||||
text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
|
||||
text: `Automatic installation is not supported for ${ideName}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
@@ -11,9 +11,14 @@ import type { SlashCommand, type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.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 {
|
||||
getErrorMessage,
|
||||
loadServerHierarchicalMemory,
|
||||
QWEN_DIR,
|
||||
setGeminiMdFilename,
|
||||
type FileDiscoveryService,
|
||||
type LoadServerHierarchicalMemoryResponse,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
@@ -31,7 +36,18 @@ 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 mockReadFile = readFile as unknown as Mock;
|
||||
|
||||
describe('memoryCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
@@ -52,6 +68,10 @@ describe('memoryCommand', () => {
|
||||
let mockGetGeminiMdFileCount: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
setGeminiMdFilename('QWEN.md');
|
||||
mockReadFile.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
showCommand = getSubCommand('show');
|
||||
|
||||
mockGetUserMemory = vi.fn();
|
||||
@@ -102,6 +122,52 @@ describe('memoryCommand', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
|
||||
import {
|
||||
getErrorMessage,
|
||||
getCurrentGeminiMdFilename,
|
||||
loadServerHierarchicalMemory,
|
||||
QWEN_DIR,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import path from 'node:path';
|
||||
import os from 'os';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs/promises';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
@@ -56,7 +57,12 @@ export const memoryCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
try {
|
||||
const projectMemoryPath = path.join(process.cwd(), 'QWEN.md');
|
||||
const workingDir =
|
||||
context.services.config?.getWorkingDir?.() ?? process.cwd();
|
||||
const projectMemoryPath = path.join(
|
||||
workingDir,
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
const memoryContent = await fs.readFile(
|
||||
projectMemoryPath,
|
||||
'utf-8',
|
||||
@@ -104,7 +110,7 @@ export const memoryCommand: SlashCommand = {
|
||||
const globalMemoryPath = path.join(
|
||||
os.homedir(),
|
||||
QWEN_DIR,
|
||||
'QWEN.md',
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
const globalMemoryContent = await fs.readFile(
|
||||
globalMemoryPath,
|
||||
|
||||
@@ -13,12 +13,6 @@ import {
|
||||
type ContentGeneratorConfig,
|
||||
type Config,
|
||||
} 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
|
||||
function createMockConfig(
|
||||
@@ -31,9 +25,6 @@ function createMockConfig(
|
||||
|
||||
describe('modelCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const mockGetAvailableModelsForAuthType = vi.mocked(
|
||||
availableModelsModule.getAvailableModelsForAuthType,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
@@ -87,10 +78,6 @@ describe('modelCommand', () => {
|
||||
});
|
||||
|
||||
it('should return dialog action for QWEN_OAUTH auth type', async () => {
|
||||
mockGetAvailableModelsForAuthType.mockReturnValue([
|
||||
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
|
||||
]);
|
||||
|
||||
const mockConfig = createMockConfig({
|
||||
model: 'test-model',
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
@@ -105,11 +92,7 @@ describe('modelCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return dialog action for USE_OPENAI auth type when model is available', async () => {
|
||||
mockGetAvailableModelsForAuthType.mockReturnValue([
|
||||
{ id: 'gpt-4', label: 'gpt-4' },
|
||||
]);
|
||||
|
||||
it('should return dialog action for USE_OPENAI auth type', async () => {
|
||||
const mockConfig = createMockConfig({
|
||||
model: 'test-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
@@ -124,28 +107,7 @@ describe('modelCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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([]);
|
||||
|
||||
it('should return dialog action for unsupported auth types', async () => {
|
||||
const mockConfig = createMockConfig({
|
||||
model: 'test-model',
|
||||
authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType,
|
||||
@@ -155,10 +117,8 @@ describe('modelCommand', () => {
|
||||
const result = await modelCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'No models available for the current authentication type (UNSUPPORTED_AUTH_TYPE).',
|
||||
type: 'dialog',
|
||||
dialog: 'model',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
MessageActionReturn,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const modelCommand: SlashCommand = {
|
||||
@@ -30,7 +29,7 @@ export const modelCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Configuration not available.',
|
||||
content: t('Configuration not available.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,22 +51,6 @@ 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 {
|
||||
type: 'dialog',
|
||||
dialog: 'model',
|
||||
|
||||
132
packages/cli/src/ui/commands/skillsCommand.ts
Normal file
132
packages/cli/src/ui/commands/skillsCommand.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
CommandKind,
|
||||
type CommandCompletionItem,
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
} from './types.js';
|
||||
import { MessageType, type HistoryItemSkillsList } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { AsyncFzf } from 'fzf';
|
||||
import type { SkillConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const skillsCommand: SlashCommand = {
|
||||
name: 'skills',
|
||||
get description() {
|
||||
return t('List available skills.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext, args?: string) => {
|
||||
const rawArgs = args?.trim() ?? '';
|
||||
const [skillName = ''] = rawArgs.split(/\s+/);
|
||||
|
||||
const skillManager = context.services.config?.getSkillManager();
|
||||
if (!skillManager) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Could not retrieve skill manager.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const skills = await skillManager.listSkills();
|
||||
if (skills.length === 0) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('No skills are currently available.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!skillName) {
|
||||
const sortedSkills = [...skills].sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
const skillsListItem: HistoryItemSkillsList = {
|
||||
type: MessageType.SKILLS_LIST,
|
||||
skills: sortedSkills.map((skill) => ({ name: skill.name })),
|
||||
};
|
||||
context.ui.addItem(skillsListItem, Date.now());
|
||||
return;
|
||||
}
|
||||
const normalizedName = skillName.toLowerCase();
|
||||
const hasSkill = skills.some(
|
||||
(skill) => skill.name.toLowerCase() === normalizedName,
|
||||
);
|
||||
|
||||
if (!hasSkill) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Unknown skill: {{name}}', { name: skillName }),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawInput = context.invocation?.raw ?? `/skills ${rawArgs}`;
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: rawInput }],
|
||||
};
|
||||
},
|
||||
completion: async (
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
): Promise<CommandCompletionItem[]> => {
|
||||
const skillManager = context.services.config?.getSkillManager();
|
||||
if (!skillManager) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const skills = await skillManager.listSkills();
|
||||
const normalizedPartial = partialArg.trim();
|
||||
const matches = await getSkillMatches(skills, normalizedPartial);
|
||||
|
||||
return matches.map((skill) => ({
|
||||
value: skill.name,
|
||||
description: skill.description,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
async function getSkillMatches(
|
||||
skills: SkillConfig[],
|
||||
query: string,
|
||||
): Promise<SkillConfig[]> {
|
||||
if (!query) {
|
||||
return skills;
|
||||
}
|
||||
|
||||
const names = skills.map((skill) => skill.name);
|
||||
const skillMap = new Map(skills.map((skill) => [skill.name, skill]));
|
||||
|
||||
try {
|
||||
const fzf = new AsyncFzf(names, {
|
||||
fuzzy: 'v2',
|
||||
casing: 'case-insensitive',
|
||||
});
|
||||
const results = (await fzf.find(query)) as Array<{ item: string }>;
|
||||
return results
|
||||
.map((result) => skillMap.get(result.item))
|
||||
.filter((skill): skill is SkillConfig => !!skill);
|
||||
} catch (error) {
|
||||
console.error('[skillsCommand] Fuzzy match failed:', error);
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return skills.filter((skill) =>
|
||||
skill.name.toLowerCase().startsWith(lowerQuery),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,12 @@ export enum CommandKind {
|
||||
MCP_PROMPT = 'mcp-prompt',
|
||||
}
|
||||
|
||||
export interface CommandCompletionItem {
|
||||
value: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// The standardized contract for any command in the system.
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
@@ -234,7 +240,7 @@ export interface SlashCommand {
|
||||
completion?: (
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
) => Promise<string[]>;
|
||||
) => Promise<Array<string | CommandCompletionItem> | null>;
|
||||
|
||||
subCommands?: SlashCommand[];
|
||||
}
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
} from '../../utils/systemInfoFields.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
type AboutBoxProps = ExtendedSystemInfo;
|
||||
type AboutBoxProps = ExtendedSystemInfo & {
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export const AboutBox: React.FC<AboutBoxProps> = (props) => {
|
||||
export const AboutBox: React.FC<AboutBoxProps> = ({ width, ...props }) => {
|
||||
const fields = getSystemInfoFields(props);
|
||||
|
||||
return (
|
||||
@@ -26,8 +28,7 @@ export const AboutBox: React.FC<AboutBoxProps> = (props) => {
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
marginY={1}
|
||||
width="100%"
|
||||
width={width}
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
|
||||
93
packages/cli/src/ui/components/AppHeader.test.tsx
Normal file
93
packages/cli/src/ui/components/AppHeader.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||
|
||||
const createSettings = (options?: { hideTips?: boolean }): LoadedSettings =>
|
||||
({
|
||||
merged: {
|
||||
ui: {
|
||||
hideTips: options?.hideTips ?? true,
|
||||
},
|
||||
},
|
||||
}) as never;
|
||||
|
||||
const createMockConfig = (overrides = {}) => ({
|
||||
getContentGeneratorConfig: vi.fn(() => ({ authType: undefined })),
|
||||
getModel: vi.fn(() => 'gemini-pro'),
|
||||
getTargetDir: vi.fn(() => '/projects/qwen-code'),
|
||||
getMcpServers: vi.fn(() => ({})),
|
||||
getBlockedMcpServers: vi.fn(() => []),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getScreenReader: vi.fn(() => false),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
({
|
||||
branchName: 'main',
|
||||
nightly: false,
|
||||
debugMessage: '',
|
||||
sessionStats: {
|
||||
lastPromptTokenCount: 0,
|
||||
},
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
const renderWithProviders = (
|
||||
uiState: UIState,
|
||||
settings = createSettings(),
|
||||
config = createMockConfig(),
|
||||
) => {
|
||||
useTerminalSizeMock.mockReturnValue({ columns: 120, rows: 24 });
|
||||
return render(
|
||||
<ConfigContext.Provider value={config as never}>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<VimModeProvider settings={settings}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<AppHeader version="1.2.3" />
|
||||
</UIStateContext.Provider>
|
||||
</VimModeProvider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AppHeader />', () => {
|
||||
it('shows the working directory', () => {
|
||||
const { lastFrame } = renderWithProviders(createMockUIState());
|
||||
expect(lastFrame()).toContain('/projects/qwen-code');
|
||||
});
|
||||
|
||||
it('hides the header when screen reader is enabled', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
createMockUIState(),
|
||||
createSettings(),
|
||||
createMockConfig({ getScreenReader: vi.fn(() => true) }),
|
||||
);
|
||||
// When screen reader is enabled, header is not rendered
|
||||
expect(lastFrame()).not.toContain('/projects/qwen-code');
|
||||
expect(lastFrame()).not.toContain('Qwen Code');
|
||||
});
|
||||
|
||||
it('shows the header with all info when banner is visible', () => {
|
||||
const { lastFrame } = renderWithProviders(createMockUIState());
|
||||
expect(lastFrame()).toContain('>_ Qwen Code');
|
||||
expect(lastFrame()).toContain('gemini-pro');
|
||||
expect(lastFrame()).toContain('/projects/qwen-code');
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,6 @@ import { Header } from './Header.js';
|
||||
import { Tips } from './Tips.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
interface AppHeaderProps {
|
||||
version: string;
|
||||
@@ -18,16 +17,25 @@ interface AppHeaderProps {
|
||||
export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const { nightly } = useUIState();
|
||||
|
||||
const contentGeneratorConfig = config.getContentGeneratorConfig();
|
||||
const authType = contentGeneratorConfig?.authType;
|
||||
const model = config.getModel();
|
||||
const targetDir = config.getTargetDir();
|
||||
const showBanner = !config.getScreenReader();
|
||||
const showTips = !(settings.merged.ui?.hideTips || config.getScreenReader());
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
|
||||
<Header version={version} nightly={nightly} />
|
||||
)}
|
||||
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
|
||||
<Tips config={config} />
|
||||
{showBanner && (
|
||||
<Header
|
||||
version={version}
|
||||
authType={authType}
|
||||
model={model}
|
||||
workingDirectory={targetDir}
|
||||
/>
|
||||
)}
|
||||
{showTips && <Tips />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ export function ApprovalModeDialog({
|
||||
}: ApprovalModeDialogProps): React.JSX.Element {
|
||||
// Start with User scope by default
|
||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||
SettingScope.User,
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
|
||||
// Track the currently highlighted approval mode
|
||||
|
||||
@@ -5,29 +5,10 @@
|
||||
*/
|
||||
|
||||
export const shortAsciiLogo = `
|
||||
██████╗ ██╗ ██╗███████╗███╗ ██╗
|
||||
▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄▄ ▄▄▄ ▄▄
|
||||
██╔═══██╗██║ ██║██╔════╝████╗ ██║
|
||||
██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
|
||||
██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
|
||||
╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
|
||||
╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||
`;
|
||||
export const longAsciiLogo = `
|
||||
██╗ ██████╗ ██╗ ██╗███████╗███╗ ██╗
|
||||
╚██╗ ██╔═══██╗██║ ██║██╔════╝████╗ ██║
|
||||
╚██╗ ██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
|
||||
██╔╝ ██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
|
||||
██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
|
||||
╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||
`;
|
||||
|
||||
export const tinyAsciiLogo = `
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||
`;
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
type UIActions,
|
||||
} from '../contexts/UIActionsContext.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
// Mock VimModeContext hook
|
||||
vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
useVimMode: vi.fn(() => ({
|
||||
@@ -146,92 +145,33 @@ const createMockConfig = (overrides = {}) => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockSettings = (merged = {}) => ({
|
||||
merged: {
|
||||
hideFooter: false,
|
||||
showMemoryUsage: false,
|
||||
...merged,
|
||||
},
|
||||
});
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const renderComposer = (
|
||||
uiState: UIState,
|
||||
settings = createMockSettings(),
|
||||
config = createMockConfig(),
|
||||
uiActions = createMockUIActions(),
|
||||
) =>
|
||||
render(
|
||||
<ConfigContext.Provider value={config as any}>
|
||||
<SettingsContext.Provider value={settings as any}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<Composer />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<Composer />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
describe('Composer', () => {
|
||||
describe('Footer Display Settings', () => {
|
||||
it('renders Footer by default when hideFooter is false', () => {
|
||||
describe('Footer Display', () => {
|
||||
it('renders Footer by default', () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({ hideFooter: false });
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
// Smoke check that the Footer renders when enabled.
|
||||
// Smoke check that the Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
|
||||
it('does NOT render Footer when hideFooter is true', () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({ hideFooter: true });
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
// Check for content that only appears IN the Footer component itself
|
||||
expect(lastFrame()).not.toContain('[NORMAL]'); // Vim mode indicator
|
||||
expect(lastFrame()).not.toContain('(main'); // Branch name with parentheses
|
||||
});
|
||||
|
||||
it('passes correct props to Footer including vim mode when enabled', async () => {
|
||||
const uiState = createMockUIState({
|
||||
branchName: 'feature-branch',
|
||||
errorCount: 2,
|
||||
sessionStats: {
|
||||
sessionId: 'test-session',
|
||||
sessionStartTime: new Date(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
metrics: {} as any,
|
||||
lastPromptTokenCount: 150,
|
||||
promptCount: 5,
|
||||
},
|
||||
});
|
||||
const config = createMockConfig({
|
||||
getModel: vi.fn(() => 'gemini-1.5-flash'),
|
||||
getTargetDir: vi.fn(() => '/project/path'),
|
||||
getDebugMode: vi.fn(() => true),
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
hideFooter: false,
|
||||
showMemoryUsage: true,
|
||||
});
|
||||
// Mock vim mode for this test
|
||||
const { useVimMode } = await import('../contexts/VimModeContext.js');
|
||||
vi.mocked(useVimMode).mockReturnValueOnce({
|
||||
vimEnabled: true,
|
||||
vimMode: 'INSERT',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings, config);
|
||||
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
// Footer should be rendered with all the state passed through
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading Indicator', () => {
|
||||
@@ -261,7 +201,7 @@ describe('Composer', () => {
|
||||
getAccessibility: vi.fn(() => ({ disableLoadingPhrases: true })),
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, undefined, config);
|
||||
const { lastFrame } = renderComposer(uiState, config);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
@@ -318,7 +258,8 @@ describe('Composer', () => {
|
||||
});
|
||||
|
||||
describe('Context and Status Display', () => {
|
||||
it('shows ContextSummaryDisplay in normal state', () => {
|
||||
// Note: ContextSummaryDisplay and status prompts are now rendered in Footer, not Composer
|
||||
it('shows empty space in normal state (ContextSummaryDisplay moved to Footer)', () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlCPressedOnce: false,
|
||||
ctrlDPressedOnce: false,
|
||||
@@ -327,37 +268,43 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ContextSummaryDisplay');
|
||||
// ContextSummaryDisplay is now in Footer, so we just verify normal state renders
|
||||
expect(lastFrame()).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
|
||||
// Note: Ctrl+C, Ctrl+D, and Escape prompts are now rendered in Footer component
|
||||
// These are tested in Footer.test.tsx
|
||||
it('renders Footer which handles Ctrl+C exit prompt', () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlCPressedOnce: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
|
||||
// Ctrl+C prompt is now inside Footer, verify Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
|
||||
it('shows Ctrl+D exit prompt when ctrlDPressedOnce is true', () => {
|
||||
it('renders Footer which handles Ctrl+D exit prompt', () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlDPressedOnce: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
|
||||
// Ctrl+D prompt is now inside Footer, verify Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
|
||||
it('shows escape prompt when showEscapePrompt is true', () => {
|
||||
it('renders Footer which handles escape prompt', () => {
|
||||
const uiState = createMockUIState({
|
||||
showEscapePrompt: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('Press Esc again to clear');
|
||||
// Escape prompt is now inside Footer, verify Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -382,7 +329,9 @@ describe('Composer', () => {
|
||||
expect(lastFrame()).not.toContain('InputPrompt');
|
||||
});
|
||||
|
||||
it('shows AutoAcceptIndicator when approval mode is not default and shell mode is inactive', () => {
|
||||
// Note: AutoAcceptIndicator and ShellModeIndicator are now rendered inside Footer component
|
||||
// These are tested in Footer.test.tsx
|
||||
it('renders Footer which contains AutoAcceptIndicator when approval mode is not default', () => {
|
||||
const uiState = createMockUIState({
|
||||
showAutoAcceptIndicator: ApprovalMode.YOLO,
|
||||
shellModeActive: false,
|
||||
@@ -390,17 +339,19 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('AutoAcceptIndicator');
|
||||
// AutoAcceptIndicator is now inside Footer, verify Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
|
||||
it('shows ShellModeIndicator when shell mode is active', () => {
|
||||
it('renders Footer which contains ShellModeIndicator when shell mode is active', () => {
|
||||
const uiState = createMockUIState({
|
||||
shellModeActive: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ShellModeIndicator');
|
||||
// ShellModeIndicator is now inside Footer, verify Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,42 +4,46 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
||||
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||
import { InputPrompt, calculatePromptWidths } from './InputPrompt.js';
|
||||
import { Footer } from './Footer.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
|
||||
import { KeyboardShortcuts } from './KeyboardShortcuts.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const Composer = () => {
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const { vimEnabled } = useVimMode();
|
||||
const terminalWidth = process.stdout.columns;
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
|
||||
|
||||
const { contextFileNames, showAutoAcceptIndicator } = uiState;
|
||||
const { showAutoAcceptIndicator } = uiState;
|
||||
|
||||
// State for keyboard shortcuts display toggle
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
const handleToggleShortcuts = useCallback(() => {
|
||||
setShowShortcuts((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// State for suggestions visibility
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const handleSuggestionsVisibilityChange = useCallback((visible: boolean) => {
|
||||
setShowSuggestions(visible);
|
||||
}, []);
|
||||
|
||||
// Use the container width of InputPrompt for width of DetailedMessagesDisplay
|
||||
const { containerWidth } = useMemo(
|
||||
@@ -48,7 +52,7 @@ export const Composer = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{!uiState.embeddedShellFocused && (
|
||||
<LoadingIndicator
|
||||
thought={
|
||||
@@ -70,55 +74,6 @@ export const Composer = () => {
|
||||
|
||||
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
|
||||
|
||||
<Box
|
||||
marginTop={1}
|
||||
justifyContent={
|
||||
settings.merged.ui?.hideContextSummary
|
||||
? 'flex-start'
|
||||
: 'space-between'
|
||||
}
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box marginRight={1}>
|
||||
{process.env['GEMINI_SYSTEM_MD'] && (
|
||||
<Text color={theme.status.error}>|⌐■_■| </Text>
|
||||
)}
|
||||
{uiState.ctrlCPressedOnce ? (
|
||||
<Text color={theme.status.warning}>
|
||||
{t('Press Ctrl+C again to exit.')}
|
||||
</Text>
|
||||
) : uiState.ctrlDPressedOnce ? (
|
||||
<Text color={theme.status.warning}>
|
||||
{t('Press Ctrl+D again to exit.')}
|
||||
</Text>
|
||||
) : uiState.showEscapePrompt ? (
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Press Esc again to clear.')}
|
||||
</Text>
|
||||
) : (
|
||||
!settings.merged.ui?.hideContextSummary && (
|
||||
<ContextSummaryDisplay
|
||||
ideContext={uiState.ideContextState}
|
||||
geminiMdFileCount={uiState.geminiMdFileCount}
|
||||
contextFileNames={contextFileNames}
|
||||
mcpServers={config.getMcpServers()}
|
||||
blockedMcpServers={config.getBlockedMcpServers()}
|
||||
showToolDescriptions={uiState.showToolDescriptions}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
<Box paddingTop={isNarrow ? 1 : 0}>
|
||||
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
|
||||
!uiState.shellModeActive && (
|
||||
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
|
||||
)}
|
||||
{uiState.shellModeActive && <ShellModeIndicator />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{uiState.showErrorDetails && (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
@@ -149,6 +104,9 @@ export const Composer = () => {
|
||||
setShellModeActive={uiActions.setShellModeActive}
|
||||
approvalMode={showAutoAcceptIndicator}
|
||||
onEscapePromptChange={uiActions.onEscapePromptChange}
|
||||
onToggleShortcuts={handleToggleShortcuts}
|
||||
showShortcuts={showShortcuts}
|
||||
onSuggestionsVisibilityChange={handleSuggestionsVisibilityChange}
|
||||
focus={true}
|
||||
vimHandleInput={uiActions.vimHandleInput}
|
||||
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
||||
@@ -160,7 +118,13 @@ export const Composer = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!settings.merged.ui?.hideFooter && !isScreenReaderEnabled && <Footer />}
|
||||
{/* Exclusive area: only one component visible at a time */}
|
||||
{!showSuggestions &&
|
||||
(showShortcuts ? (
|
||||
<KeyboardShortcuts />
|
||||
) : (
|
||||
!isScreenReaderEnabled && <Footer />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('ConsentPrompt', () => {
|
||||
{
|
||||
isPending: true,
|
||||
text: prompt,
|
||||
terminalWidth,
|
||||
contentWidth: terminalWidth,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
|
||||
<MarkdownDisplay
|
||||
isPending={true}
|
||||
text={prompt}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={terminalWidth}
|
||||
/>
|
||||
) : (
|
||||
prompt
|
||||
|
||||
@@ -17,15 +17,19 @@ export const ContextUsageDisplay = ({
|
||||
model: string;
|
||||
terminalWidth: number;
|
||||
}) => {
|
||||
const percentage = promptTokenCount / tokenLimit(model);
|
||||
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
|
||||
if (promptTokenCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = terminalWidth < 100 ? '%' : '% context left';
|
||||
const percentage = promptTokenCount / tokenLimit(model);
|
||||
const percentageUsed = (percentage * 100).toFixed(1);
|
||||
|
||||
const label = terminalWidth < 100 ? '% used' : '% context used';
|
||||
|
||||
return (
|
||||
<Text color={theme.text.secondary}>
|
||||
({percentageLeft}
|
||||
{label})
|
||||
{percentageUsed}
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,7 +25,6 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { AuthState } from '../types.js';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import process from 'node:process';
|
||||
@@ -202,7 +201,7 @@ export const DialogManager = ({
|
||||
return (
|
||||
<OpenAIKeyPrompt
|
||||
onSubmit={(apiKey, baseUrl, model) => {
|
||||
uiActions.handleAuthSelect(AuthType.USE_OPENAI, SettingScope.User, {
|
||||
uiActions.handleAuthSelect(AuthType.USE_OPENAI, {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
|
||||
@@ -8,41 +8,23 @@ import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Footer } from './Footer.js';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
import { tildeifyPath } from '@qwen-code/qwen-code-core';
|
||||
import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...original,
|
||||
shortenPath: (p: string, len: number) => {
|
||||
if (p.length > len) {
|
||||
return '...' + p.slice(p.length - len + 3);
|
||||
}
|
||||
return p;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
model: 'gemini-pro',
|
||||
targetDir:
|
||||
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
|
||||
branchName: 'main',
|
||||
};
|
||||
|
||||
const createMockConfig = (overrides = {}) => ({
|
||||
getModel: vi.fn(() => defaultProps.model),
|
||||
getTargetDir: vi.fn(() => defaultProps.targetDir),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getMcpServers: vi.fn(() => ({})),
|
||||
getBlockedMcpServers: vi.fn(() => []),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -51,46 +33,31 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
sessionStats: {
|
||||
lastPromptTokenCount: 100,
|
||||
},
|
||||
branchName: defaultProps.branchName,
|
||||
geminiMdFileCount: 0,
|
||||
contextFileNames: [],
|
||||
showToolDescriptions: false,
|
||||
ideContextState: undefined,
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
const createDefaultSettings = (
|
||||
options: {
|
||||
showMemoryUsage?: boolean;
|
||||
hideCWD?: boolean;
|
||||
hideSandboxStatus?: boolean;
|
||||
hideModelInfo?: boolean;
|
||||
} = {},
|
||||
): LoadedSettings =>
|
||||
const createMockSettings = (): LoadedSettings =>
|
||||
({
|
||||
merged: {
|
||||
ui: {
|
||||
showMemoryUsage: options.showMemoryUsage,
|
||||
footer: {
|
||||
hideCWD: options.hideCWD,
|
||||
hideSandboxStatus: options.hideSandboxStatus,
|
||||
hideModelInfo: options.hideModelInfo,
|
||||
},
|
||||
general: {
|
||||
vimMode: false,
|
||||
},
|
||||
},
|
||||
}) as never;
|
||||
}) as LoadedSettings;
|
||||
|
||||
const renderWithWidth = (
|
||||
width: number,
|
||||
uiState: UIState,
|
||||
settings: LoadedSettings = createDefaultSettings(),
|
||||
) => {
|
||||
const renderWithWidth = (width: number, uiState: UIState) => {
|
||||
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
|
||||
return render(
|
||||
<ConfigContext.Provider value={createMockConfig() as never}>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<VimModeProvider settings={settings}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<Footer />
|
||||
</UIStateContext.Provider>
|
||||
</VimModeProvider>
|
||||
</SettingsContext.Provider>
|
||||
<VimModeProvider settings={createMockSettings()}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<Footer />
|
||||
</UIStateContext.Provider>
|
||||
</VimModeProvider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
};
|
||||
@@ -101,161 +68,28 @@ describe('<Footer />', () => {
|
||||
expect(lastFrame()).toBeDefined();
|
||||
});
|
||||
|
||||
describe('path display', () => {
|
||||
it('should display a shortened path on a narrow terminal', () => {
|
||||
const { lastFrame } = renderWithWidth(79, createMockUIState());
|
||||
const tildePath = tildeifyPath(defaultProps.targetDir);
|
||||
const pathLength = Math.max(20, Math.floor(79 * 0.25));
|
||||
const expectedPath =
|
||||
'...' + tildePath.slice(tildePath.length - pathLength + 3);
|
||||
expect(lastFrame()).toContain(expectedPath);
|
||||
});
|
||||
|
||||
it('should use wide layout at 80 columns', () => {
|
||||
const { lastFrame } = renderWithWidth(80, createMockUIState());
|
||||
const tildePath = tildeifyPath(defaultProps.targetDir);
|
||||
const expectedPath =
|
||||
'...' + tildePath.slice(tildePath.length - 80 * 0.25 + 3);
|
||||
expect(lastFrame()).toContain(expectedPath);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays the branch name when provided', () => {
|
||||
it('does not display the working directory or branch name', () => {
|
||||
const { lastFrame } = renderWithWidth(120, createMockUIState());
|
||||
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
|
||||
expect(lastFrame()).not.toMatch(/\(.*\*\)/);
|
||||
});
|
||||
|
||||
it('does not display the branch name when not provided', () => {
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
branchName: undefined,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
|
||||
});
|
||||
|
||||
it('displays the model name and context percentage', () => {
|
||||
it('displays the context percentage', () => {
|
||||
const { lastFrame } = renderWithWidth(120, createMockUIState());
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
|
||||
expect(lastFrame()).toMatch(/\d+(\.\d+)?% context used/);
|
||||
});
|
||||
|
||||
it('displays the model name and abbreviated context percentage', () => {
|
||||
it('displays the abbreviated context percentage on narrow terminal', () => {
|
||||
const { lastFrame } = renderWithWidth(99, createMockUIState());
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\(\d+%\)/);
|
||||
expect(lastFrame()).toMatch(/\d+%/);
|
||||
});
|
||||
|
||||
describe('sandbox and trust info', () => {
|
||||
it('should display untrusted when isTrustedFolder is false', () => {
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
isTrustedFolder: false,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toContain('untrusted');
|
||||
});
|
||||
|
||||
it('should display custom sandbox info when SANDBOX env is set', () => {
|
||||
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
isTrustedFolder: undefined,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toContain('test');
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', () => {
|
||||
vi.stubEnv('SANDBOX', 'sandbox-exec');
|
||||
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
isTrustedFolder: true,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', () => {
|
||||
// Clear any SANDBOX env var that might be set.
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
isTrustedFolder: true,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toContain('no sandbox');
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should prioritize untrusted message over sandbox info', () => {
|
||||
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
isTrustedFolder: false,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toContain('untrusted');
|
||||
expect(lastFrame()).not.toMatch(/test-sandbox/s);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer configuration filtering (golden snapshots)', () => {
|
||||
it('renders complete footer with all sections visible (baseline)', () => {
|
||||
describe('footer rendering (golden snapshots)', () => {
|
||||
it('renders complete footer on wide terminal', () => {
|
||||
const { lastFrame } = renderWithWidth(120, createMockUIState());
|
||||
expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
|
||||
});
|
||||
|
||||
it('renders footer with all optional sections hidden (minimal footer)', () => {
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState(),
|
||||
createDefaultSettings({
|
||||
hideCWD: true,
|
||||
hideSandboxStatus: true,
|
||||
hideModelInfo: true,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('footer-minimal');
|
||||
});
|
||||
|
||||
it('renders footer with only model info hidden (partial filtering)', () => {
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState(),
|
||||
createDefaultSettings({
|
||||
hideCWD: false,
|
||||
hideSandboxStatus: false,
|
||||
hideModelInfo: true,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('footer-no-model');
|
||||
});
|
||||
|
||||
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', () => {
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState(),
|
||||
createDefaultSettings({
|
||||
hideCWD: true,
|
||||
hideSandboxStatus: false,
|
||||
hideModelInfo: true,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('footer-only-sandbox');
|
||||
});
|
||||
|
||||
it('renders complete footer in narrow terminal (baseline narrow)', () => {
|
||||
it('renders complete footer on narrow terminal', () => {
|
||||
const { lastFrame } = renderWithWidth(79, createMockUIState());
|
||||
expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
|
||||
});
|
||||
|
||||
@@ -7,159 +7,134 @@
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
|
||||
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
|
||||
import process from 'node:process';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { DebugProfiler } from './DebugProfiler.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const { vimEnabled, vimMode } = useVimMode();
|
||||
|
||||
const {
|
||||
model,
|
||||
targetDir,
|
||||
debugMode,
|
||||
branchName,
|
||||
debugMessage,
|
||||
errorCount,
|
||||
showErrorDetails,
|
||||
promptTokenCount,
|
||||
nightly,
|
||||
isTrustedFolder,
|
||||
showAutoAcceptIndicator,
|
||||
} = {
|
||||
model: config.getModel(),
|
||||
targetDir: config.getTargetDir(),
|
||||
debugMode: config.getDebugMode(),
|
||||
branchName: uiState.branchName,
|
||||
debugMessage: uiState.debugMessage,
|
||||
errorCount: uiState.errorCount,
|
||||
showErrorDetails: uiState.showErrorDetails,
|
||||
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
|
||||
nightly: uiState.nightly,
|
||||
isTrustedFolder: uiState.isTrustedFolder,
|
||||
showAutoAcceptIndicator: uiState.showAutoAcceptIndicator,
|
||||
};
|
||||
|
||||
const showMemoryUsage =
|
||||
config.getDebugMode() || settings.merged.ui?.showMemoryUsage || false;
|
||||
const hideCWD = settings.merged.ui?.footer?.hideCWD || false;
|
||||
const hideSandboxStatus =
|
||||
settings.merged.ui?.footer?.hideSandboxStatus || false;
|
||||
const hideModelInfo = settings.merged.ui?.footer?.hideModelInfo || false;
|
||||
const showErrorIndicator = !showErrorDetails && errorCount > 0;
|
||||
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
|
||||
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.25));
|
||||
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
|
||||
// Determine sandbox info from environment
|
||||
const sandboxEnv = process.env['SANDBOX'];
|
||||
const sandboxInfo = sandboxEnv
|
||||
? sandboxEnv === 'sandbox-exec'
|
||||
? 'seatbelt'
|
||||
: sandboxEnv.startsWith('qwen-code')
|
||||
? 'docker'
|
||||
: sandboxEnv
|
||||
: null;
|
||||
|
||||
const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
|
||||
const displayVimMode = vimEnabled ? vimMode : undefined;
|
||||
// Check if debug mode is enabled
|
||||
const debugMode = config.getDebugMode();
|
||||
|
||||
// Left section should show exactly ONE thing at any time, in priority order.
|
||||
const leftContent = uiState.ctrlCPressedOnce ? (
|
||||
<Text color={theme.status.warning}>{t('Press Ctrl+C again to exit.')}</Text>
|
||||
) : uiState.ctrlDPressedOnce ? (
|
||||
<Text color={theme.status.warning}>{t('Press Ctrl+D again to exit.')}</Text>
|
||||
) : uiState.showEscapePrompt ? (
|
||||
<Text color={theme.text.secondary}>{t('Press Esc again to clear.')}</Text>
|
||||
) : vimEnabled && vimMode === 'INSERT' ? (
|
||||
<Text color={theme.text.secondary}>-- INSERT --</Text>
|
||||
) : uiState.shellModeActive ? (
|
||||
<ShellModeIndicator />
|
||||
) : showAutoAcceptIndicator !== undefined &&
|
||||
showAutoAcceptIndicator !== ApprovalMode.DEFAULT ? (
|
||||
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{t('? for shortcuts')}</Text>
|
||||
);
|
||||
|
||||
const rightItems: Array<{ key: string; node: React.ReactNode }> = [];
|
||||
if (sandboxInfo) {
|
||||
rightItems.push({
|
||||
key: 'sandbox',
|
||||
node: <Text color={theme.status.success}>🔒 {sandboxInfo}</Text>,
|
||||
});
|
||||
}
|
||||
if (debugMode) {
|
||||
rightItems.push({
|
||||
key: 'debug',
|
||||
node: <Text color={theme.status.warning}>Debug Mode</Text>,
|
||||
});
|
||||
}
|
||||
if (promptTokenCount > 0) {
|
||||
rightItems.push({
|
||||
key: 'context',
|
||||
node: (
|
||||
<Text color={theme.text.accent}>
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Text>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showErrorIndicator) {
|
||||
rightItems.push({
|
||||
key: 'errors',
|
||||
node: <ConsoleSummaryDisplay errorCount={errorCount} />,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
justifyContent={justifyContent}
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
>
|
||||
{(debugMode || displayVimMode || !hideCWD) && (
|
||||
<Box>
|
||||
{debugMode && <DebugProfiler />}
|
||||
{displayVimMode && (
|
||||
<Text color={theme.text.secondary}>[{displayVimMode}] </Text>
|
||||
)}
|
||||
{!hideCWD &&
|
||||
(nightly ? (
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>
|
||||
{displayPath}
|
||||
{branchName && <Text> ({branchName}*)</Text>}
|
||||
</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text color={theme.text.link}>
|
||||
{displayPath}
|
||||
{branchName && (
|
||||
<Text color={theme.text.secondary}> ({branchName}*)</Text>
|
||||
)}
|
||||
</Text>
|
||||
))}
|
||||
{debugMode && (
|
||||
<Text color={theme.status.error}>
|
||||
{' ' + (debugMessage || '--debug')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{/* Left Section: Exactly one status line (exit prompts / mode indicator / default hint) */}
|
||||
<Box
|
||||
marginLeft={2}
|
||||
justifyContent="flex-start"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{leftContent}
|
||||
</Box>
|
||||
|
||||
{/* Middle Section: Centered Trust/Sandbox Info */}
|
||||
{!hideSandboxStatus && (
|
||||
<Box
|
||||
flexGrow={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
display="flex"
|
||||
>
|
||||
{isTrustedFolder === false ? (
|
||||
<Text color={theme.status.warning}>untrusted</Text>
|
||||
) : process.env['SANDBOX'] &&
|
||||
process.env['SANDBOX'] !== 'sandbox-exec' ? (
|
||||
<Text color="green">
|
||||
{process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
|
||||
</Text>
|
||||
) : process.env['SANDBOX'] === 'sandbox-exec' ? (
|
||||
<Text color={theme.status.warning}>
|
||||
macOS Seatbelt{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
({process.env['SEATBELT_PROFILE']})
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.status.error}>
|
||||
no sandbox
|
||||
{terminalWidth >= 100 && (
|
||||
<Text color={theme.text.secondary}> (see /docs)</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Right Section: Gemini Label and Console Summary */}
|
||||
{!hideModelInfo && (
|
||||
<Box alignItems="center" justifyContent="flex-end">
|
||||
<Box alignItems="center">
|
||||
<Text color={theme.text.accent}>
|
||||
{model}{' '}
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Text>
|
||||
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||
{/* Right Section: Sandbox Info, Debug Mode, Context Usage, and Console Summary */}
|
||||
<Box alignItems="center" justifyContent="flex-end" marginRight={2}>
|
||||
{rightItems.map(({ key, node }, index) => (
|
||||
<Box key={key} alignItems="center">
|
||||
{index > 0 && <Text color={theme.text.secondary}> | </Text>}
|
||||
{node}
|
||||
</Box>
|
||||
<Box alignItems="center" paddingLeft={2}>
|
||||
{!showErrorDetails && errorCount > 0 && (
|
||||
<Box>
|
||||
<Text color={theme.ui.symbol}>| </Text>
|
||||
<ConsoleSummaryDisplay errorCount={errorCount} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,39 +6,96 @@
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { Header } from './Header.js';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
import { longAsciiLogo } from './AsciiArt.js';
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||
|
||||
const defaultProps = {
|
||||
version: '1.0.0',
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
model: 'qwen-coder-plus',
|
||||
workingDirectory: '/home/user/projects/test',
|
||||
};
|
||||
|
||||
describe('<Header />', () => {
|
||||
beforeEach(() => {});
|
||||
|
||||
it('renders the long logo on a wide terminal', () => {
|
||||
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
|
||||
columns: 120,
|
||||
rows: 20,
|
||||
});
|
||||
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
|
||||
expect(lastFrame()).toContain(longAsciiLogo);
|
||||
beforeEach(() => {
|
||||
// Default to wide terminal (shows both logo and info panel)
|
||||
useTerminalSizeMock.mockReturnValue({ columns: 120, rows: 24 });
|
||||
});
|
||||
|
||||
it('renders custom ASCII art when provided', () => {
|
||||
it('renders the ASCII logo on wide terminal', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
// Check that parts of the shortAsciiLogo are rendered
|
||||
expect(lastFrame()).toContain('██╔═══██╗');
|
||||
});
|
||||
|
||||
it('hides the ASCII logo on narrow terminal', () => {
|
||||
useTerminalSizeMock.mockReturnValue({ columns: 60, rows: 24 });
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
// Should not contain the logo but still show the info panel
|
||||
expect(lastFrame()).not.toContain('██╔═══██╗');
|
||||
expect(lastFrame()).toContain('>_ Qwen Code');
|
||||
});
|
||||
|
||||
it('renders custom ASCII art when provided on wide terminal', () => {
|
||||
const customArt = 'CUSTOM ART';
|
||||
const { lastFrame } = render(
|
||||
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
|
||||
<Header {...defaultProps} customAsciiArt={customArt} />,
|
||||
);
|
||||
expect(lastFrame()).toContain(customArt);
|
||||
});
|
||||
|
||||
it('displays the version number when nightly is true', () => {
|
||||
const { lastFrame } = render(<Header version="1.0.0" nightly={true} />);
|
||||
it('displays the version number', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('v1.0.0');
|
||||
});
|
||||
|
||||
it('does not display the version number when nightly is false', () => {
|
||||
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
|
||||
expect(lastFrame()).not.toContain('v1.0.0');
|
||||
it('displays Qwen Code title with >_ prefix', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('>_ Qwen Code');
|
||||
});
|
||||
|
||||
it('displays auth type and model', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('Qwen OAuth');
|
||||
expect(lastFrame()).toContain('qwen-coder-plus');
|
||||
});
|
||||
|
||||
it('displays working directory', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('/home/user/projects/test');
|
||||
});
|
||||
|
||||
it('renders a custom working directory display', () => {
|
||||
const { lastFrame } = render(
|
||||
<Header {...defaultProps} workingDirectory="custom display" />,
|
||||
);
|
||||
expect(lastFrame()).toContain('custom display');
|
||||
});
|
||||
|
||||
it('displays working directory without branch name', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
// Branch name is no longer shown in header
|
||||
expect(lastFrame()).toContain('/home/user/projects/test');
|
||||
expect(lastFrame()).not.toContain('(main*)');
|
||||
});
|
||||
|
||||
it('formats home directory with tilde', () => {
|
||||
const { lastFrame } = render(
|
||||
<Header {...defaultProps} workingDirectory="/Users/testuser/projects" />,
|
||||
);
|
||||
// The actual home dir replacement depends on os.homedir()
|
||||
// Just verify the path is shown
|
||||
expect(lastFrame()).toContain('projects');
|
||||
});
|
||||
|
||||
it('renders with border around info panel', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
// Check for border characters (round border style uses these)
|
||||
expect(lastFrame()).toContain('╭');
|
||||
expect(lastFrame()).toContain('╯');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,64 +7,172 @@
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { AuthType, shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
|
||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||
import { shortAsciiLogo } from './AsciiArt.js';
|
||||
import { getAsciiArtWidth, getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
|
||||
interface HeaderProps {
|
||||
customAsciiArt?: string; // For user-defined ASCII art
|
||||
version: string;
|
||||
nightly: boolean;
|
||||
authType?: AuthType;
|
||||
model: string;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
function titleizeAuthType(value: string): string {
|
||||
return value
|
||||
.split(/[-_]/g)
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
if (part.toLowerCase() === 'ai') {
|
||||
return 'AI';
|
||||
}
|
||||
return part.charAt(0).toUpperCase() + part.slice(1);
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// Format auth type for display
|
||||
function formatAuthType(authType?: AuthType): string {
|
||||
if (!authType) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
switch (authType) {
|
||||
case AuthType.QWEN_OAUTH:
|
||||
return 'Qwen OAuth';
|
||||
case AuthType.USE_OPENAI:
|
||||
return 'OpenAI';
|
||||
case AuthType.USE_GEMINI:
|
||||
return 'Gemini';
|
||||
case AuthType.USE_VERTEX_AI:
|
||||
return 'Vertex AI';
|
||||
case AuthType.USE_ANTHROPIC:
|
||||
return 'Anthropic';
|
||||
default:
|
||||
return titleizeAuthType(String(authType));
|
||||
}
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
customAsciiArt,
|
||||
version,
|
||||
nightly,
|
||||
authType,
|
||||
model,
|
||||
workingDirectory,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
let displayTitle;
|
||||
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
|
||||
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
|
||||
|
||||
if (customAsciiArt) {
|
||||
displayTitle = customAsciiArt;
|
||||
} else if (terminalWidth >= widthOfLongLogo) {
|
||||
displayTitle = longAsciiLogo;
|
||||
} else if (terminalWidth >= widthOfShortLogo) {
|
||||
displayTitle = shortAsciiLogo;
|
||||
} else {
|
||||
displayTitle = tinyAsciiLogo;
|
||||
}
|
||||
const displayLogo = customAsciiArt ?? shortAsciiLogo;
|
||||
const logoWidth = getAsciiArtWidth(displayLogo);
|
||||
const formattedAuthType = formatAuthType(authType);
|
||||
|
||||
const artWidth = getAsciiArtWidth(displayTitle);
|
||||
// Calculate available space properly:
|
||||
// First determine if logo can be shown, then use remaining space for path
|
||||
const containerMarginX = 2; // marginLeft + marginRight on the outer container
|
||||
const logoGap = 2; // Gap between logo and info panel
|
||||
const infoPanelPaddingX = 1;
|
||||
const infoPanelBorderWidth = 2; // left + right border
|
||||
const infoPanelChromeWidth = infoPanelBorderWidth + infoPanelPaddingX * 2;
|
||||
const minPathLength = 40; // Minimum readable path length
|
||||
const minInfoPanelWidth = minPathLength + infoPanelChromeWidth;
|
||||
|
||||
const availableTerminalWidth = Math.max(
|
||||
0,
|
||||
terminalWidth - containerMarginX * 2,
|
||||
);
|
||||
|
||||
// Check if we have enough space for logo + gap + minimum info panel
|
||||
const showLogo =
|
||||
availableTerminalWidth >= logoWidth + logoGap + minInfoPanelWidth;
|
||||
|
||||
// Calculate available width for info panel (use all remaining space)
|
||||
const availableInfoPanelWidth = showLogo
|
||||
? availableTerminalWidth - logoWidth - logoGap
|
||||
: availableTerminalWidth;
|
||||
|
||||
// Calculate max path length (subtract padding/borders from available space)
|
||||
const maxPathLength = Math.max(
|
||||
0,
|
||||
availableInfoPanelWidth - infoPanelChromeWidth,
|
||||
);
|
||||
|
||||
const infoPanelContentWidth = Math.max(
|
||||
0,
|
||||
availableInfoPanelWidth - infoPanelChromeWidth,
|
||||
);
|
||||
const authModelText = `${formattedAuthType} | ${model}`;
|
||||
const authHintText = ' (/auth to change)';
|
||||
const showAuthHint =
|
||||
infoPanelContentWidth > 0 &&
|
||||
getCachedStringWidth(authModelText + authHintText) <= infoPanelContentWidth;
|
||||
|
||||
// Now shorten the path to fit the available space
|
||||
const tildeifiedPath = tildeifyPath(workingDirectory);
|
||||
const shortenedPath = shortenPath(tildeifiedPath, Math.max(3, maxPathLength));
|
||||
const displayPath =
|
||||
maxPathLength <= 0
|
||||
? ''
|
||||
: shortenedPath.length > maxPathLength
|
||||
? shortenedPath.slice(0, maxPathLength)
|
||||
: shortenedPath;
|
||||
|
||||
// Use theme gradient colors if available, otherwise use text colors (excluding primary)
|
||||
const gradientColors = theme.ui.gradient || [
|
||||
theme.text.secondary,
|
||||
theme.text.link,
|
||||
theme.text.accent,
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
alignItems="flex-start"
|
||||
width={artWidth}
|
||||
flexShrink={0}
|
||||
flexDirection="column"
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
marginX={containerMarginX}
|
||||
width={availableTerminalWidth}
|
||||
>
|
||||
{theme.ui.gradient ? (
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>{displayTitle}</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text>{displayTitle}</Text>
|
||||
)}
|
||||
{nightly && (
|
||||
<Box width="100%" flexDirection="row" justifyContent="flex-end">
|
||||
{theme.ui.gradient ? (
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>v{version}</Text>
|
||||
{/* Left side: ASCII logo (only if enough space) */}
|
||||
{showLogo && (
|
||||
<>
|
||||
<Box flexShrink={0}>
|
||||
<Gradient colors={gradientColors}>
|
||||
<Text>{displayLogo}</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text>v{version}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Fixed gap between logo and info panel */}
|
||||
<Box width={logoGap} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Right side: Info panel (flexible width) */}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={infoPanelPaddingX}
|
||||
flexGrow={1}
|
||||
>
|
||||
{/* Title line: >_ Qwen Code (v{version}) */}
|
||||
<Text>
|
||||
<Text bold color={theme.text.accent}>
|
||||
>_ Qwen Code
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> (v{version})</Text>
|
||||
</Text>
|
||||
{/* Empty line for spacing */}
|
||||
<Text> </Text>
|
||||
{/* Auth and Model line */}
|
||||
<Text>
|
||||
<Text color={theme.text.secondary}>{authModelText}</Text>
|
||||
{showAuthHint && (
|
||||
<Text color={theme.text.secondary}>{authHintText}</Text>
|
||||
)}
|
||||
</Text>
|
||||
{/* Directory line */}
|
||||
<Text color={theme.text.secondary}>{displayPath}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,15 +12,16 @@ import { t } from '../../i18n/index.js';
|
||||
|
||||
interface Help {
|
||||
commands: readonly SlashCommand[];
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const Help: React.FC<Help> = ({ commands }) => (
|
||||
export const Help: React.FC<Help> = ({ commands, width }) => (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginBottom={1}
|
||||
borderColor={theme.border.default}
|
||||
borderStyle="round"
|
||||
padding={1}
|
||||
width={width}
|
||||
>
|
||||
{/* Basics */}
|
||||
<Text bold color={theme.text.primary}>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Help } from './Help.js';
|
||||
import type { SlashCommand } from '../commands/types.js';
|
||||
import { ExtensionsList } from './views/ExtensionsList.js';
|
||||
import { getMCPServerStatus } from '@qwen-code/qwen-code-core';
|
||||
import { SkillsList } from './views/SkillsList.js';
|
||||
import { ToolsList } from './views/ToolsList.js';
|
||||
import { McpStatus } from './views/McpStatus.js';
|
||||
|
||||
@@ -37,6 +38,7 @@ interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
mainAreaWidth?: number;
|
||||
isPending: boolean;
|
||||
isFocused?: boolean;
|
||||
commands?: readonly SlashCommand[];
|
||||
@@ -49,6 +51,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
item,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
mainAreaWidth,
|
||||
isPending,
|
||||
commands,
|
||||
isFocused = true,
|
||||
@@ -57,9 +60,16 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
availableTerminalHeightGemini,
|
||||
}) => {
|
||||
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
|
||||
const contentWidth = terminalWidth - 4;
|
||||
const boxWidth = mainAreaWidth || contentWidth;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={itemForDisplay.id}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
key={itemForDisplay.id}
|
||||
marginLeft={2}
|
||||
marginRight={2}
|
||||
>
|
||||
{/* Render standard message types */}
|
||||
{itemForDisplay.type === 'user' && (
|
||||
<UserMessage text={itemForDisplay.text} />
|
||||
@@ -74,7 +84,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_content' && (
|
||||
@@ -84,7 +94,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_thought' && (
|
||||
@@ -94,7 +104,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_thought_content' && (
|
||||
@@ -104,7 +114,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'info' && (
|
||||
@@ -117,25 +127,32 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
<ErrorMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
{itemForDisplay.type === 'about' && (
|
||||
<AboutBox {...itemForDisplay.systemInfo} />
|
||||
<AboutBox {...itemForDisplay.systemInfo} width={boxWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'help' && commands && (
|
||||
<Help commands={commands} />
|
||||
<Help commands={commands} width={boxWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'stats' && (
|
||||
<StatsDisplay duration={itemForDisplay.duration} />
|
||||
<StatsDisplay duration={itemForDisplay.duration} width={boxWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'model_stats' && (
|
||||
<ModelStatsDisplay width={boxWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'tool_stats' && (
|
||||
<ToolStatsDisplay width={boxWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'model_stats' && <ModelStatsDisplay />}
|
||||
{itemForDisplay.type === 'tool_stats' && <ToolStatsDisplay />}
|
||||
{itemForDisplay.type === 'quit' && (
|
||||
<SessionSummaryDisplay duration={itemForDisplay.duration} />
|
||||
<SessionSummaryDisplay
|
||||
duration={itemForDisplay.duration}
|
||||
width={boxWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'tool_group' && (
|
||||
<ToolGroupMessage
|
||||
toolCalls={itemForDisplay.tools}
|
||||
groupId={itemForDisplay.id}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth}
|
||||
isFocused={isFocused}
|
||||
activeShellPtyId={activeShellPtyId}
|
||||
embeddedShellFocused={embeddedShellFocused}
|
||||
@@ -148,11 +165,14 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
{itemForDisplay.type === 'extensions_list' && <ExtensionsList />}
|
||||
{itemForDisplay.type === 'tools_list' && (
|
||||
<ToolsList
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth}
|
||||
tools={itemForDisplay.tools}
|
||||
showDescriptions={itemForDisplay.showDescriptions}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'skills_list' && (
|
||||
<SkillsList skills={itemForDisplay.skills} />
|
||||
)}
|
||||
{itemForDisplay.type === 'mcp_status' && (
|
||||
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
|
||||
)}
|
||||
|
||||
@@ -52,6 +52,9 @@ export interface InputPromptProps {
|
||||
setShellModeActive: (value: boolean) => void;
|
||||
approvalMode: ApprovalMode;
|
||||
onEscapePromptChange?: (showPrompt: boolean) => void;
|
||||
onToggleShortcuts?: () => void;
|
||||
showShortcuts?: boolean;
|
||||
onSuggestionsVisibilityChange?: (visible: boolean) => void;
|
||||
vimHandleInput?: (key: Key) => boolean;
|
||||
isEmbeddedShellFocused?: boolean;
|
||||
}
|
||||
@@ -96,6 +99,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setShellModeActive,
|
||||
approvalMode,
|
||||
onEscapePromptChange,
|
||||
onToggleShortcuts,
|
||||
showShortcuts,
|
||||
onSuggestionsVisibilityChange,
|
||||
vimHandleInput,
|
||||
isEmbeddedShellFocused,
|
||||
}) => {
|
||||
@@ -338,11 +344,31 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.text === '' &&
|
||||
!completion.showSuggestions
|
||||
) {
|
||||
// Hide shortcuts when toggling shell mode
|
||||
if (showShortcuts && onToggleShortcuts) {
|
||||
onToggleShortcuts();
|
||||
}
|
||||
setShellModeActive(!shellModeActive);
|
||||
buffer.setText(''); // Clear the '!' from input
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle keyboard shortcuts display with "?" when buffer is empty
|
||||
if (
|
||||
key.sequence === '?' &&
|
||||
buffer.text === '' &&
|
||||
!completion.showSuggestions &&
|
||||
onToggleShortcuts
|
||||
) {
|
||||
onToggleShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide shortcuts on any other key press
|
||||
if (showShortcuts && onToggleShortcuts) {
|
||||
onToggleShortcuts();
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
const cancelSearch = (
|
||||
setActive: (active: boolean) => void,
|
||||
@@ -670,6 +696,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
recentPasteTime,
|
||||
commandSearchActive,
|
||||
commandSearchCompletion,
|
||||
onToggleShortcuts,
|
||||
showShortcuts,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -689,6 +717,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const activeCompletion = getActiveCompletion();
|
||||
const shouldShowSuggestions = activeCompletion.showSuggestions;
|
||||
|
||||
// Notify parent about suggestions visibility changes
|
||||
useEffect(() => {
|
||||
if (onSuggestionsVisibilityChange) {
|
||||
onSuggestionsVisibilityChange(shouldShowSuggestions);
|
||||
}
|
||||
}, [shouldShowSuggestions, onSuggestionsVisibilityChange]);
|
||||
|
||||
const showAutoAcceptStyling =
|
||||
!shellModeActive && approvalMode === ApprovalMode.AUTO_EDIT;
|
||||
const showYoloStyling =
|
||||
@@ -721,7 +756,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text
|
||||
color={statusColor ?? theme.text.accent}
|
||||
@@ -852,7 +886,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
{shouldShowSuggestions && (
|
||||
<Box paddingRight={2}>
|
||||
<Box marginLeft={2} marginRight={2}>
|
||||
<SuggestionsDisplay
|
||||
suggestions={activeCompletion.suggestions}
|
||||
activeIndex={activeCompletion.activeSuggestionIndex}
|
||||
|
||||
118
packages/cli/src/ui/components/KeyboardShortcuts.tsx
Normal file
118
packages/cli/src/ui/components/KeyboardShortcuts.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface Shortcut {
|
||||
key: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Platform-specific key mappings
|
||||
const getNewlineKey = () =>
|
||||
process.platform === 'win32' ? 'ctrl+enter' : 'ctrl+j';
|
||||
const getPasteKey = () => (process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v');
|
||||
const getExternalEditorKey = () =>
|
||||
process.platform === 'darwin' ? 'ctrl+x' : 'ctrl+x';
|
||||
|
||||
// Generate shortcuts with translations (called at render time)
|
||||
const getShortcuts = (): Shortcut[] => [
|
||||
{ key: '!', description: t('for shell mode') },
|
||||
{ key: '/', description: t('for commands') },
|
||||
{ key: '@', description: t('for file paths') },
|
||||
{ key: 'esc esc', description: t('to clear input') },
|
||||
{ key: 'shift+tab', description: t('to cycle approvals') },
|
||||
{ key: 'ctrl+c', description: t('to quit') },
|
||||
{ key: getNewlineKey(), description: t('for newline') + ' ⏎' },
|
||||
{ key: 'ctrl+l', description: t('to clear screen') },
|
||||
{ key: 'ctrl+r', description: t('to search history') },
|
||||
{ key: getPasteKey(), description: t('to paste images') },
|
||||
{ key: getExternalEditorKey(), description: t('for external editor') },
|
||||
];
|
||||
|
||||
const ShortcutItem: React.FC<{ shortcut: Shortcut }> = ({ shortcut }) => (
|
||||
<Text color={theme.text.secondary}>
|
||||
<Text color={theme.text.primary}>{shortcut.key}</Text>{' '}
|
||||
{shortcut.description}
|
||||
</Text>
|
||||
);
|
||||
|
||||
// Layout constants
|
||||
const COLUMN_GAP = 4;
|
||||
const MARGIN_LEFT = 2;
|
||||
const MARGIN_RIGHT = 2;
|
||||
|
||||
// Column distribution for different layouts (3+4+4 for 3 cols, 6+5 for 2 cols)
|
||||
const COLUMN_SPLITS: Record<number, number[]> = {
|
||||
3: [3, 4, 4],
|
||||
2: [6, 5],
|
||||
1: [11],
|
||||
};
|
||||
|
||||
export const KeyboardShortcuts: React.FC = () => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const shortcuts = getShortcuts();
|
||||
|
||||
// Helper to calculate width needed for a column layout
|
||||
const getShortcutWidth = (shortcut: Shortcut) =>
|
||||
shortcut.key.length + 1 + shortcut.description.length;
|
||||
|
||||
const calculateLayoutWidth = (splits: number[]): number => {
|
||||
let startIndex = 0;
|
||||
let totalWidth = 0;
|
||||
splits.forEach((count, colIndex) => {
|
||||
const columnItems = shortcuts.slice(startIndex, startIndex + count);
|
||||
const columnWidth = Math.max(...columnItems.map(getShortcutWidth));
|
||||
totalWidth += columnWidth;
|
||||
if (colIndex < splits.length - 1) {
|
||||
totalWidth += COLUMN_GAP;
|
||||
}
|
||||
startIndex += count;
|
||||
});
|
||||
return totalWidth;
|
||||
};
|
||||
|
||||
// Calculate number of columns based on terminal width and actual content
|
||||
const availableWidth = terminalWidth - MARGIN_LEFT - MARGIN_RIGHT;
|
||||
const width3Col = calculateLayoutWidth(COLUMN_SPLITS[3]);
|
||||
const width2Col = calculateLayoutWidth(COLUMN_SPLITS[2]);
|
||||
|
||||
const numColumns =
|
||||
availableWidth >= width3Col ? 3 : availableWidth >= width2Col ? 2 : 1;
|
||||
|
||||
// Split shortcuts into columns using predefined distribution
|
||||
const splits = COLUMN_SPLITS[numColumns];
|
||||
const columns: Shortcut[][] = [];
|
||||
let startIndex = 0;
|
||||
for (const count of splits) {
|
||||
columns.push(shortcuts.slice(startIndex, startIndex + count));
|
||||
startIndex += count;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
marginLeft={MARGIN_LEFT}
|
||||
marginRight={MARGIN_RIGHT}
|
||||
>
|
||||
{columns.map((column, colIndex) => (
|
||||
<Box
|
||||
key={colIndex}
|
||||
flexDirection="column"
|
||||
marginRight={colIndex < numColumns - 1 ? COLUMN_GAP : 0}
|
||||
>
|
||||
{column.map((shortcut) => (
|
||||
<ShortcutItem key={shortcut.key} shortcut={shortcut} />
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -23,6 +23,7 @@ export const MainContent = () => {
|
||||
const uiState = useUIState();
|
||||
const {
|
||||
pendingHistoryItems,
|
||||
terminalWidth,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
availableTerminalHeight,
|
||||
@@ -36,7 +37,8 @@ export const MainContent = () => {
|
||||
<AppHeader key="app-header" version={version} />,
|
||||
...uiState.history.map((h) => (
|
||||
<HistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
mainAreaWidth={mainAreaWidth}
|
||||
availableTerminalHeight={staticAreaMaxItemHeight}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={h.id}
|
||||
@@ -57,7 +59,8 @@ export const MainContent = () => {
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
mainAreaWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
|
||||
@@ -10,7 +10,11 @@ import { ModelDialog } from './ModelDialog.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
AVAILABLE_MODELS_QWEN,
|
||||
MAINLINE_CODER,
|
||||
@@ -36,18 +40,29 @@ const renderComponent = (
|
||||
};
|
||||
const combinedProps = { ...defaultProps, ...props };
|
||||
|
||||
const mockSettings = {
|
||||
isTrusted: true,
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const mockConfig = contextValue
|
||||
? ({
|
||||
// --- Functions used by ModelDialog ---
|
||||
getModel: vi.fn(() => MAINLINE_CODER),
|
||||
setModel: vi.fn(),
|
||||
setModel: vi.fn().mockResolvedValue(undefined),
|
||||
switchModel: vi.fn().mockResolvedValue(undefined),
|
||||
getAuthType: vi.fn(() => 'qwen-oauth'),
|
||||
|
||||
// --- Functions used by ClearcutLogger ---
|
||||
getUsageStatisticsEnabled: vi.fn(() => true),
|
||||
getSessionId: vi.fn(() => 'mock-session-id'),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })),
|
||||
getContentGeneratorConfig: vi.fn(() => ({
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
model: MAINLINE_CODER,
|
||||
})),
|
||||
getUseSmartEdit: vi.fn(() => false),
|
||||
getUseModelRouter: vi.fn(() => false),
|
||||
getProxy: vi.fn(() => undefined),
|
||||
@@ -58,21 +73,27 @@ const renderComponent = (
|
||||
: undefined;
|
||||
|
||||
const renderResult = render(
|
||||
<ConfigContext.Provider value={mockConfig}>
|
||||
<ModelDialog {...combinedProps} />
|
||||
</ConfigContext.Provider>,
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<ConfigContext.Provider value={mockConfig}>
|
||||
<ModelDialog {...combinedProps} />
|
||||
</ConfigContext.Provider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
return {
|
||||
...renderResult,
|
||||
props: combinedProps,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
};
|
||||
};
|
||||
|
||||
describe('<ModelDialog />', () => {
|
||||
beforeEach(() => {
|
||||
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(() => {
|
||||
@@ -91,8 +112,12 @@ describe('<ModelDialog />', () => {
|
||||
|
||||
const props = mockedSelect.mock.calls[0][0];
|
||||
expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length);
|
||||
expect(props.items[0].value).toBe(MAINLINE_CODER);
|
||||
expect(props.items[1].value).toBe(MAINLINE_VLM);
|
||||
expect(props.items[0].value).toBe(
|
||||
`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`,
|
||||
);
|
||||
expect(props.items[1].value).toBe(
|
||||
`${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`,
|
||||
);
|
||||
expect(props.showNumbers).toBe(true);
|
||||
});
|
||||
|
||||
@@ -139,16 +164,93 @@ describe('<ModelDialog />', () => {
|
||||
expect(mockedSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => {
|
||||
const { props, mockConfig } = renderComponent({}, {}); // Pass empty object for contextValue
|
||||
it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => {
|
||||
const { props, mockConfig, mockSettings } = renderComponent({}, {}); // Pass empty object for contextValue
|
||||
|
||||
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
||||
expect(childOnSelect).toBeDefined();
|
||||
|
||||
childOnSelect(MAINLINE_CODER);
|
||||
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
|
||||
|
||||
// Assert against the default mock provided by renderComponent
|
||||
expect(mockConfig?.setModel).toHaveBeenCalledWith(MAINLINE_CODER);
|
||||
expect(mockConfig?.switchModel).toHaveBeenCalledWith(
|
||||
AuthType.QWEN_OAUTH,
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -193,17 +295,25 @@ describe('<ModelDialog />', () => {
|
||||
it('updates initialIndex when config context changes', () => {
|
||||
const mockGetModel = vi.fn(() => MAINLINE_CODER);
|
||||
const mockGetAuthType = vi.fn(() => 'qwen-oauth');
|
||||
const mockSettings = {
|
||||
isTrusted: true,
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
const { rerender } = render(
|
||||
<ConfigContext.Provider
|
||||
value={
|
||||
{
|
||||
getModel: mockGetModel,
|
||||
getAuthType: mockGetAuthType,
|
||||
} as unknown as Config
|
||||
}
|
||||
>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>,
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<ConfigContext.Provider
|
||||
value={
|
||||
{
|
||||
getModel: mockGetModel,
|
||||
getAuthType: mockGetAuthType,
|
||||
} as unknown as Config
|
||||
}
|
||||
>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
|
||||
@@ -215,9 +325,11 @@ describe('<ModelDialog />', () => {
|
||||
} as unknown as Config;
|
||||
|
||||
rerender(
|
||||
<ConfigContext.Provider value={newMockConfig}>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>,
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<ConfigContext.Provider value={newMockConfig}>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
// Should be called at least twice: initial render + re-render after context change
|
||||
|
||||
@@ -5,52 +5,210 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
AuthType,
|
||||
ModelSlashCommandEvent,
|
||||
logModelSlashCommand,
|
||||
type ContentGeneratorConfig,
|
||||
type ContentGeneratorConfigSource,
|
||||
type ContentGeneratorConfigSources,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import {
|
||||
getAvailableModelsForAuthType,
|
||||
MAINLINE_CODER,
|
||||
} from '../models/availableModels.js';
|
||||
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface ModelDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatSourceBadge(
|
||||
source: ContentGeneratorConfigSource | undefined,
|
||||
): string | undefined {
|
||||
if (!source) return undefined;
|
||||
|
||||
switch (source.kind) {
|
||||
case 'cli':
|
||||
return source.detail ? `CLI ${source.detail}` : 'CLI';
|
||||
case 'env':
|
||||
return source.envKey ? `ENV ${source.envKey}` : 'ENV';
|
||||
case 'settings':
|
||||
return source.settingsPath
|
||||
? `Settings ${source.settingsPath}`
|
||||
: 'Settings';
|
||||
case 'modelProviders': {
|
||||
const suffix =
|
||||
source.authType && source.modelId
|
||||
? `${source.authType}:${source.modelId}`
|
||||
: source.authType
|
||||
? `${source.authType}`
|
||||
: source.modelId
|
||||
? `${source.modelId}`
|
||||
: '';
|
||||
return suffix ? `ModelProviders ${suffix}` : 'ModelProviders';
|
||||
}
|
||||
case 'default':
|
||||
return source.detail ? `Default ${source.detail}` : 'Default';
|
||||
case 'computed':
|
||||
return source.detail ? `Computed ${source.detail}` : 'Computed';
|
||||
case 'programmatic':
|
||||
return source.detail ? `Programmatic ${source.detail}` : 'Programmatic';
|
||||
case 'unknown':
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readSourcesFromConfig(config: unknown): ContentGeneratorConfigSources {
|
||||
if (!config) {
|
||||
return {};
|
||||
}
|
||||
const maybe = config as {
|
||||
getContentGeneratorConfigSources?: () => ContentGeneratorConfigSources;
|
||||
};
|
||||
return maybe.getContentGeneratorConfigSources?.() ?? {};
|
||||
}
|
||||
|
||||
function maskApiKey(apiKey: string | undefined): string {
|
||||
if (!apiKey) return '(not set)';
|
||||
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 {
|
||||
const config = useContext(ConfigContext);
|
||||
const uiState = useContext(UIStateContext);
|
||||
const settings = useSettings();
|
||||
|
||||
// Get auth type from config, default to QWEN_OAUTH if not available
|
||||
const authType = config?.getAuthType() ?? AuthType.QWEN_OAUTH;
|
||||
// Local error state for displaying errors within the dialog
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Get available models based on auth type
|
||||
const availableModels = useMemo(
|
||||
() => getAvailableModelsForAuthType(authType),
|
||||
[authType],
|
||||
);
|
||||
const authType = config?.getAuthType();
|
||||
const effectiveConfig =
|
||||
(config?.getContentGeneratorConfig?.() as
|
||||
| ContentGeneratorConfig
|
||||
| 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(
|
||||
() =>
|
||||
availableModels.map((model) => ({
|
||||
value: model.id,
|
||||
title: model.label,
|
||||
description: model.description || '',
|
||||
key: model.id,
|
||||
})),
|
||||
[availableModels],
|
||||
availableModelEntries.map(({ authType: t2, model }) => {
|
||||
const value = `${t2}::${model.id}`;
|
||||
const title = (
|
||||
<Text>
|
||||
<Text bold color={theme.text.accent}>
|
||||
[{t2}]
|
||||
</Text>
|
||||
<Text>{` ${model.label}`}</Text>
|
||||
</Text>
|
||||
);
|
||||
const description = model.description || '';
|
||||
return {
|
||||
value,
|
||||
title,
|
||||
description,
|
||||
key: value,
|
||||
};
|
||||
}),
|
||||
[availableModelEntries],
|
||||
);
|
||||
|
||||
// Determine the Preferred Model (read once when the dialog opens).
|
||||
const preferredModel = config?.getModel() || MAINLINE_CODER;
|
||||
const preferredModelId = config?.getModel() || MAINLINE_CODER;
|
||||
const preferredKey = authType ? `${authType}::${preferredModelId}` : '';
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
@@ -61,25 +219,83 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Calculate the initial index based on the preferred model.
|
||||
const initialIndex = useMemo(
|
||||
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredModel),
|
||||
[MODEL_OPTIONS, preferredModel],
|
||||
);
|
||||
const initialIndex = useMemo(() => {
|
||||
const index = MODEL_OPTIONS.findIndex(
|
||||
(option) => option.value === preferredKey,
|
||||
);
|
||||
return index === -1 ? 0 : index;
|
||||
}, [MODEL_OPTIONS, preferredKey]);
|
||||
|
||||
// Handle selection internally (Autonomous Dialog).
|
||||
const handleSelect = useCallback(
|
||||
(model: string) => {
|
||||
async (selected: 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) {
|
||||
config.setModel(model);
|
||||
const event = new ModelSlashCommandEvent(model);
|
||||
try {
|
||||
await config.switchModel(
|
||||
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);
|
||||
|
||||
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();
|
||||
},
|
||||
[config, onClose],
|
||||
[authType, config, onClose, settings, uiState, setErrorMessage],
|
||||
);
|
||||
|
||||
const hasModels = MODEL_OPTIONS.length > 0;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
@@ -89,14 +305,73 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>{t('Select Model')}</Text>
|
||||
<Box marginTop={1}>
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={MODEL_OPTIONS}
|
||||
onSelect={handleSelect}
|
||||
initialIndex={initialIndex}
|
||||
showNumbers={true}
|
||||
/>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Current (effective) configuration')}
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<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>
|
||||
|
||||
{!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">
|
||||
<Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text>
|
||||
</Box>
|
||||
|
||||
@@ -50,7 +50,13 @@ const StatRow: React.FC<StatRowProps> = ({
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const ModelStatsDisplay: React.FC = () => {
|
||||
interface ModelStatsDisplayProps {
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
|
||||
width,
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
const { models } = stats.metrics;
|
||||
const activeModels = Object.entries(models).filter(
|
||||
@@ -64,6 +70,7 @@ export const ModelStatsDisplay: React.FC = () => {
|
||||
borderColor={theme.border.default}
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={width}
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
{t('No API calls have been made in this session.')}
|
||||
@@ -93,6 +100,7 @@ export const ModelStatsDisplay: React.FC = () => {
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={width}
|
||||
>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{t('Model Stats For Nerds')}
|
||||
|
||||
@@ -34,7 +34,7 @@ export const PlanSummaryDisplay: React.FC<PlanSummaryDisplayProps> = ({
|
||||
text={plan}
|
||||
isPending={false}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
contentWidth={childWidth}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ export const QuittingDisplay = () => {
|
||||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
const availableTerminalHeight = terminalHeight;
|
||||
const { mainAreaWidth } = uiState;
|
||||
|
||||
if (!uiState.quittingMessages) {
|
||||
return null;
|
||||
@@ -28,6 +29,7 @@ export const QuittingDisplay = () => {
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
mainAreaWidth={mainAreaWidth}
|
||||
item={item}
|
||||
isPending={false}
|
||||
/>
|
||||
|
||||
@@ -127,8 +127,8 @@ export function SessionPicker(props: SessionPickerProps) {
|
||||
|
||||
const { columns: width, rows: height } = useTerminalSize();
|
||||
|
||||
// Calculate box width (width + 6 for border padding)
|
||||
const boxWidth = width + 6;
|
||||
// Calculate box width (marginX={2})
|
||||
const boxWidth = width - 4;
|
||||
// Calculate visible items (same heuristic as before)
|
||||
// Reserved space: header (1), footer (1), separators (2), borders (2)
|
||||
const reservedLines = 6;
|
||||
@@ -179,7 +179,7 @@ export function SessionPicker(props: SessionPickerProps) {
|
||||
|
||||
{/* Separator */}
|
||||
<Box>
|
||||
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
|
||||
<Text color={theme.border.default}>{'─'.repeat(boxWidth - 2)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Session list */}
|
||||
@@ -212,7 +212,7 @@ export function SessionPicker(props: SessionPickerProps) {
|
||||
isLast={visibleIndex === picker.visibleSessions.length - 1}
|
||||
showScrollUp={picker.showScrollUp}
|
||||
showScrollDown={picker.showScrollDown}
|
||||
maxPromptWidth={width}
|
||||
maxPromptWidth={boxWidth - 6}
|
||||
prefixChars={PREFIX_CHARS}
|
||||
boldSelectedPrefix={false}
|
||||
/>
|
||||
@@ -223,7 +223,7 @@ export function SessionPicker(props: SessionPickerProps) {
|
||||
|
||||
{/* Separator */}
|
||||
<Box>
|
||||
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
|
||||
<Text color={theme.border.default}>{'─'.repeat(boxWidth - 2)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -14,10 +14,12 @@ import { t } from '../../i18n/index.js';
|
||||
|
||||
interface SessionSummaryDisplayProps {
|
||||
duration: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||
duration,
|
||||
width,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
const { stats } = useSessionStats();
|
||||
@@ -32,6 +34,7 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||
<StatsDisplay
|
||||
title={t('Agent powering down. Goodbye!')}
|
||||
duration={duration}
|
||||
width={width}
|
||||
/>
|
||||
{hasMessages && canResume && (
|
||||
<Box marginTop={1}>
|
||||
|
||||
@@ -1275,7 +1275,6 @@ describe('SettingsDialog', () => {
|
||||
ui: {
|
||||
hideWindowTitle: true,
|
||||
hideTips: true,
|
||||
showMemoryUsage: true,
|
||||
showLineNumbers: true,
|
||||
showCitations: true,
|
||||
accessibility: {
|
||||
@@ -1324,7 +1323,6 @@ describe('SettingsDialog', () => {
|
||||
disableAutoUpdate: true,
|
||||
},
|
||||
ui: {
|
||||
showMemoryUsage: true,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
tools: {
|
||||
@@ -1375,9 +1373,7 @@ describe('SettingsDialog', () => {
|
||||
vimMode: true,
|
||||
disableAutoUpdate: false,
|
||||
},
|
||||
ui: {
|
||||
showMemoryUsage: true,
|
||||
},
|
||||
ui: {},
|
||||
},
|
||||
);
|
||||
const onSelect = vi.fn();
|
||||
@@ -1438,7 +1434,6 @@ describe('SettingsDialog', () => {
|
||||
disableLoadingPhrases: true,
|
||||
screenReader: true,
|
||||
},
|
||||
showMemoryUsage: true,
|
||||
showLineNumbers: true,
|
||||
},
|
||||
general: {
|
||||
@@ -1520,7 +1515,6 @@ describe('SettingsDialog', () => {
|
||||
ui: {
|
||||
hideWindowTitle: false,
|
||||
hideTips: false,
|
||||
showMemoryUsage: false,
|
||||
showLineNumbers: false,
|
||||
showCitations: false,
|
||||
accessibility: {
|
||||
|
||||
@@ -87,7 +87,13 @@ export async function showResumeSessionPicker(
|
||||
let selectedId: string | undefined;
|
||||
|
||||
const { unmount, waitUntilExit } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<KeypressProvider
|
||||
kittyProtocolEnabled={false}
|
||||
pasteWorkaround={
|
||||
process.platform === 'win32' ||
|
||||
parseInt(process.versions.node.split('.')[0], 10) < 20
|
||||
}
|
||||
>
|
||||
<StandalonePickerScreen
|
||||
sessionService={sessionService}
|
||||
onSelect={(id) => {
|
||||
|
||||
@@ -160,11 +160,13 @@ const ModelUsageTable: React.FC<{
|
||||
interface StatsDisplayProps {
|
||||
duration: string;
|
||||
title?: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
duration,
|
||||
title,
|
||||
width,
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
const { metrics } = stats;
|
||||
@@ -213,6 +215,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={width}
|
||||
>
|
||||
{renderTitle()}
|
||||
<Box height={1} />
|
||||
|
||||
@@ -42,7 +42,7 @@ export function SuggestionsDisplay({
|
||||
}: SuggestionsDisplayProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box paddingX={1} width={width}>
|
||||
<Box width={width}>
|
||||
<Text color="gray">Loading suggestions...</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -70,7 +70,7 @@ export function SuggestionsDisplay({
|
||||
mode === 'slash' ? Math.min(maxLabelLength, Math.floor(width * 0.5)) : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} width={width}>
|
||||
<Box flexDirection="column" width={width}>
|
||||
{scrollOffset > 0 && <Text color={theme.text.primary}>▲</Text>}
|
||||
|
||||
{visibleSuggestions.map((suggestion, index) => {
|
||||
@@ -106,7 +106,7 @@ export function SuggestionsDisplay({
|
||||
</Box>
|
||||
|
||||
{suggestion.description && (
|
||||
<Box flexGrow={1} paddingLeft={3}>
|
||||
<Box flexGrow={1} paddingLeft={2}>
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{suggestion.description}
|
||||
</Text>
|
||||
|
||||
@@ -258,7 +258,7 @@ def fibonacci(n):
|
||||
+ print(f"Hello, {name}!")
|
||||
`}
|
||||
availableTerminalHeight={diffHeight}
|
||||
terminalWidth={colorizeCodeWidth}
|
||||
contentWidth={colorizeCodeWidth}
|
||||
theme={previewTheme}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -4,42 +4,33 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { type Config } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface TipsProps {
|
||||
config: Config;
|
||||
}
|
||||
const startupTips = [
|
||||
'Use /compress when the conversation gets long to summarize history and free up context.',
|
||||
'Start a fresh idea with /clear or /new; the previous session stays available in history.',
|
||||
'Use /bug to submit issues to the maintainers when something goes off.',
|
||||
'Switch auth type quickly with /auth.',
|
||||
'You can run any shell commands from Qwen Code using ! (e.g. !ls).',
|
||||
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.',
|
||||
'You can resume a previous conversation by running qwen --continue or qwen --resume.',
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
|
||||
] as const;
|
||||
|
||||
export const Tips: React.FC = () => {
|
||||
const selectedTip = useMemo(() => {
|
||||
const randomIndex = Math.floor(Math.random() * startupTips.length);
|
||||
return startupTips[randomIndex];
|
||||
}, []);
|
||||
|
||||
export const Tips: React.FC<TipsProps> = ({ config }) => {
|
||||
const geminiMdFileCount = config.getGeminiMdFileCount();
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.primary}>{t('Tips for getting started:')}</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
{t('1. Ask questions, edit files, or run commands.')}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
{t('2. Be specific for the best results.')}
|
||||
</Text>
|
||||
{geminiMdFileCount === 0 && (
|
||||
<Text color={theme.text.primary}>
|
||||
3. Create{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
QWEN.md
|
||||
</Text>{' '}
|
||||
{t('files to customize your interactions with Qwen Code.')}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.primary}>
|
||||
{geminiMdFileCount === 0 ? '4.' : '3.'}{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
/help
|
||||
</Text>{' '}
|
||||
{t('for more information.')}
|
||||
<Box marginLeft={2} marginRight={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Tips: ')}
|
||||
{t(selectedTip)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -53,7 +53,13 @@ const StatRow: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolStatsDisplay: React.FC = () => {
|
||||
interface ToolStatsDisplayProps {
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const ToolStatsDisplay: React.FC<ToolStatsDisplayProps> = ({
|
||||
width,
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
const { tools } = stats.metrics;
|
||||
const activeTools = Object.entries(tools.byName).filter(
|
||||
@@ -67,6 +73,7 @@ export const ToolStatsDisplay: React.FC = () => {
|
||||
borderColor={theme.border.default}
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={width}
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
{t('No tool calls have been made in this session.')}
|
||||
@@ -101,7 +108,7 @@ export const ToolStatsDisplay: React.FC = () => {
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={70}
|
||||
width={width}
|
||||
>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{t('Tool Stats For Nerds')}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `"...s/to/make/it/long (main*) no sandbox gemini-pro (100%)"`;
|
||||
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on narrow terminal > complete-footer-narrow 1`] = `" ? for shortcuts 0.1% used"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `"...directories/to/make/it/long (main*) no sandbox (see /docs) gemini-pro (100% context left)"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `"...directories/to/make/it/long (main*) no sandbox (see /docs)"`;
|
||||
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on wide terminal > complete-footer-wide 1`] = `" ? for shortcuts 0.1% context used"`;
|
||||
|
||||
@@ -1,137 +1,137 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<HistoryItemDisplay /> > should render a full gemini item when using availableTerminalHeightGemini 1`] = `
|
||||
"✦ Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
" ✦ Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = `
|
||||
" Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
" Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > should render a truncated gemini item 1`] = `
|
||||
"✦ Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
" ✦ Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > should render a truncated gemini_content item 1`] = `
|
||||
" Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
" Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user