Compare commits

..

1 Commits

Author SHA1 Message Date
pomelo-nwu
ce53cfe0fd docs: add /model command documentation 2025-10-24 10:37:17 +08:00
215 changed files with 6197 additions and 11520 deletions

View File

@@ -101,27 +101,15 @@ jobs:
- name: 'Get the version'
id: 'version'
run: |
VERSION_ARGS=()
if [[ "${IS_NIGHTLY}" == "true" ]]; then
VERSION_ARGS+=(--type=nightly)
elif [[ "${IS_PREVIEW}" == "true" ]]; then
VERSION_ARGS+=(--type=preview)
if [[ -n "${MANUAL_VERSION}" ]]; then
VERSION_ARGS+=("--preview_version_override=${MANUAL_VERSION}")
fi
else
VERSION_ARGS+=(--type=stable)
if [[ -n "${MANUAL_VERSION}" ]]; then
VERSION_ARGS+=("--stable_version_override=${MANUAL_VERSION}")
fi
fi
VERSION_JSON=$(node scripts/get-release-version.js "${VERSION_ARGS[@]}")
VERSION_JSON=$(node scripts/get-release-version.js)
echo "RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .releaseTag)" >> "$GITHUB_OUTPUT"
echo "RELEASE_VERSION=$(echo "$VERSION_JSON" | jq -r .releaseVersion)" >> "$GITHUB_OUTPUT"
echo "NPM_TAG=$(echo "$VERSION_JSON" | jq -r .npmTag)" >> "$GITHUB_OUTPUT"
echo "PREVIOUS_RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .previousReleaseTag)" >> "$GITHUB_OUTPUT"
# Get the previous tag for release notes generation
CURRENT_TAG=$(echo "$VERSION_JSON" | jq -r .releaseTag)
PREVIOUS_TAG=$(node scripts/get-previous-tag.js "$CURRENT_TAG" || echo "")
echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> "$GITHUB_OUTPUT"
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}'
@@ -167,11 +155,7 @@ jobs:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
git add package.json package-lock.json packages/*/package.json
if git diff --staged --quiet; then
echo "No version changes to commit"
else
git commit -m "chore(release): ${RELEASE_TAG}"
fi
git commit -m "chore(release): ${RELEASE_TAG}"
if [[ "${IS_DRY_RUN}" == "false" ]]; then
echo "Pushing release branch to remote..."
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
@@ -179,9 +163,9 @@ jobs:
echo "Dry run enabled. Skipping push."
fi
- name: 'Build Bundle and Prepare Package'
- name: 'Build and Prepare Packages'
run: |-
npm run bundle
npm run build:packages
npm run prepare:package
- name: 'Configure npm for publishing'
@@ -191,10 +175,20 @@ jobs:
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
- name: 'Publish @qwen-code/qwen-code'
working-directory: 'dist'
- name: 'Publish @qwen-code/qwen-code-core'
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
npm publish --workspace=@qwen-code/qwen-code-core --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Install latest core package'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' }}
run: 'npm install @qwen-code/qwen-code-core@${{ steps.version.outputs.RELEASE_VERSION }} --workspace=@qwen-code/qwen-code --save-exact'
- name: 'Publish @qwen-code/qwen-code'
run: |-
npm publish --workspace=@qwen-code/qwen-code --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
@@ -205,13 +199,13 @@ jobs:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}'
PREVIOUS_TAG: '${{ steps.version.outputs.PREVIOUS_TAG }}'
run: |-
gh release create "${RELEASE_TAG}" \
dist/cli.js \
bundle/gemini.js \
--target "$RELEASE_BRANCH" \
--title "Release ${RELEASE_TAG}" \
--notes-start-tag "$PREVIOUS_RELEASE_TAG" \
--notes-start-tag "$PREVIOUS_TAG" \
--generate-notes
- name: 'Create Issue on Failure'

View File

@@ -309,8 +309,7 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
```
- **`tavilyApiKey`** (string):
- **Description:** API key for Tavily web search service. Used to enable the `web_search` tool functionality.
- **Note:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format.
- **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped.
- **Default:** `undefined` (web search disabled)
- **Example:** `"tavilyApiKey": "tvly-your-api-key-here"`
- **`chatCompression`** (object):
@@ -466,8 +465,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
- This is useful for development and testing.
- **`TAVILY_API_KEY`**:
- Your API key for the Tavily web search service.
- Used to enable the `web_search` tool functionality.
- **Note:** For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers to enable web search.
- Required to enable the `web_search` tool functionality.
- If not configured, the web search tool will be disabled and skipped.
- Example: `export TAVILY_API_KEY="tvly-your-api-key-here"`
## Command-Line Arguments
@@ -541,9 +540,6 @@ Arguments passed directly when running the CLI can override other configurations
- Displays the version of the CLI.
- **`--openai-logging`**:
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
- **`--openai-logging-dir <directory>`**:
- Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion.
- **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging`
- **`--tavily-api-key <api_key>`**:
- Sets the Tavily API key for web search functionality for this session.
- Example: `qwen --tavily-api-key tvly-your-api-key-here`

View File

@@ -160,30 +160,9 @@ Settings are organized into categories. All settings should be placed within the
- **Default:** `undefined`
- **`model.chatCompression.contextPercentageThreshold`** (number):
- **Description:** 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.
- **Description:** 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.
- **Default:** `0.7`
- **`model.generationConfig`** (object):
- **Description:** 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.
- **Default:** `undefined`
- **Example:**
```json
{
"model": {
"generationConfig": {
"timeout": 60000,
"disableCacheControl": false,
"samplingParams": {
"temperature": 0.2,
"top_p": 0.8,
"max_tokens": 1024
}
}
}
}
```
- **`model.skipNextSpeakerCheck`** (boolean):
- **Description:** Skip the next speaker check.
- **Default:** `false`
@@ -192,22 +171,6 @@ Settings are organized into categories. All settings should be placed within the
- **Description:** 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.
- **Default:** `false`
- **`model.skipStartupContext`** (boolean):
- **Description:** Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup.
- **Default:** `false`
- **`model.enableOpenAILogging`** (boolean):
- **Description:** Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files.
- **Default:** `false`
- **`model.openAILoggingDir`** (string):
- **Description:** Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory).
- **Default:** `undefined`
- **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`
#### `context`
- **`context.fileName`** (string or array of strings):
@@ -283,29 +246,6 @@ Settings are organized into categories. All settings should be placed within the
- It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse).
- **Default:** `undefined`
- **`tools.useRipgrep`** (boolean):
- **Description:** Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.
- **Default:** `true`
- **`tools.useBuiltinRipgrep`** (boolean):
- **Description:** Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`.
- **Default:** `true`
- **`tools.enableToolOutputTruncation`** (boolean):
- **Description:** Enable truncation of large tool outputs.
- **Default:** `true`
- **Requires restart:** Yes
- **`tools.truncateToolOutputThreshold`** (number):
- **Description:** Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools.
- **Default:** `25000`
- **Requires restart:** Yes
- **`tools.truncateToolOutputLines`** (number):
- **Description:** Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools.
- **Default:** `1000`
- **Requires restart:** Yes
#### `mcp`
- **`mcp.serverCommand`** (string):
@@ -357,8 +297,7 @@ Settings are organized into categories. All settings should be placed within the
- **Default:** `undefined`
- **`advanced.tavilyApiKey`** (string):
- **Description:** API key for Tavily web search service. Used to enable the `web_search` tool functionality.
- **Note:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format.
- **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped.
- **Default:** `undefined`
#### `mcpServers`
@@ -439,8 +378,6 @@ Here is an example of a `settings.json` file with the nested structure, new as o
"model": {
"name": "qwen3-coder-plus",
"maxSessionTurns": 10,
"enableOpenAILogging": false,
"openAILoggingDir": "~/qwen-logs",
"summarizeToolOutput": {
"run_shell_command": {
"tokenBudget": 100
@@ -529,8 +466,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
- Set to a string to customize the title of the CLI.
- **`TAVILY_API_KEY`**:
- Your API key for the Tavily web search service.
- Used to enable the `web_search` tool functionality.
- **Note:** For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers to enable web search.
- Required to enable the `web_search` tool functionality.
- If not configured, the web search tool will be disabled and skipped.
- Example: `export TAVILY_API_KEY="tvly-your-api-key-here"`
## Command-Line Arguments
@@ -578,7 +515,7 @@ Arguments passed directly when running the CLI can override other configurations
- Example: `qwen --approval-mode auto-edit`
- **`--allowed-tools <tool1,tool2,...>`**:
- A comma-separated list of tool names that will bypass the confirmation dialog.
- Example: `qwen --allowed-tools "Shell(git status)"`
- Example: `qwen --allowed-tools "ShellTool(git status)"`
- **`--telemetry`**:
- Enables [telemetry](../telemetry.md).
- **`--telemetry-target`**:
@@ -611,9 +548,6 @@ Arguments passed directly when running the CLI can override other configurations
- Displays the version of the CLI.
- **`--openai-logging`**:
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
- **`--openai-logging-dir <directory>`**:
- Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion.
- **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging`
- **`--tavily-api-key <api_key>`**:
- Sets the Tavily API key for web search functionality for this session.
- Example: `qwen --tavily-api-key tvly-your-api-key-here`

View File

@@ -21,7 +21,7 @@ The Qwen Code core (`packages/core`) features a robust system for defining, regi
- **Returning Rich Content:** Tools are not limited to returning simple text. The `llmContent` can be a `PartListUnion`, which is an array that can contain a mix of `Part` objects (for images, audio, etc.) and `string`s. This allows a single tool execution to return multiple pieces of rich content.
- **Tool Registry (`tool-registry.ts`):** A class (`ToolRegistry`) responsible for:
- **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ListFiles`, `ReadFile`).
- **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ReadFileTool`, `ShellTool`).
- **Discovering Tools:** It can also discover tools dynamically:
- **Command-based Discovery:** If `tools.toolDiscoveryCommand` is configured in settings, this command is executed. It's expected to output JSON describing custom tools, which are then registered as `DiscoveredTool` instances.
- **MCP-based Discovery:** If `mcp.mcpServerCommand` is configured, the registry can connect to a Model Context Protocol (MCP) server to list and register tools (`DiscoveredMCPTool`).
@@ -33,24 +33,20 @@ The Qwen Code core (`packages/core`) features a robust system for defining, regi
The core comes with a suite of pre-defined tools, typically found in `packages/core/src/tools/`. These include:
- **File System Tools:**
- `ListFiles` (`ls.ts`): Lists directory contents.
- `ReadFile` (`read-file.ts`): Reads the content of a single file. It takes an `absolute_path` parameter, which must be an absolute path.
- `WriteFile` (`write-file.ts`): Writes content to a file.
- `ReadManyFiles` (`read-many-files.ts`): Reads and concatenates content from multiple files or glob patterns (used by the `@` command in CLI).
- `Grep` (`grep.ts`): Searches for patterns in files.
- `Glob` (`glob.ts`): Finds files matching glob patterns.
- `Edit` (`edit.ts`): Performs in-place modifications to files (often requiring confirmation).
- `LSTool` (`ls.ts`): Lists directory contents.
- `ReadFileTool` (`read-file.ts`): Reads the content of a single file. It takes an `absolute_path` parameter, which must be an absolute path.
- `WriteFileTool` (`write-file.ts`): Writes content to a file.
- `GrepTool` (`grep.ts`): Searches for patterns in files.
- `GlobTool` (`glob.ts`): Finds files matching glob patterns.
- `EditTool` (`edit.ts`): Performs in-place modifications to files (often requiring confirmation).
- `ReadManyFilesTool` (`read-many-files.ts`): Reads and concatenates content from multiple files or glob patterns (used by the `@` command in CLI).
- **Execution Tools:**
- `Shell` (`shell.ts`): Executes arbitrary shell commands (requires careful sandboxing and user confirmation).
- `ShellTool` (`shell.ts`): Executes arbitrary shell commands (requires careful sandboxing and user confirmation).
- **Web Tools:**
- `WebFetch` (`web-fetch.ts`): Fetches content from a URL.
- `WebSearch` (`web-search.ts`): Performs a web search.
- `WebFetchTool` (`web-fetch.ts`): Fetches content from a URL.
- `WebSearchTool` (`web-search.ts`): Performs a web search.
- **Memory Tools:**
- `SaveMemory` (`memoryTool.ts`): Interacts with the AI's memory.
- **Planning Tools:**
- `Task` (`task.ts`): Delegates tasks to specialized subagents.
- `TodoWrite` (`todoWrite.ts`): Creates and manages a structured task list.
- `ExitPlanMode` (`exitPlanMode.ts`): Exits plan mode and returns to normal operation.
- `MemoryTool` (`memoryTool.ts`): Interacts with the AI's memory.
Each of these tools extends `BaseTool` and implements the required methods for its specific functionality.

View File

@@ -107,7 +107,7 @@ The `qwen-extension.json` file contains the configuration for the extension. The
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
- Note that all MCP server configuration options are supported except for `trust`.
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the extension directory. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded.
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. Note that this differs from the MCP server `excludeTools` functionality, which can be listed in the MCP server config. **Important:** Tools specified in `excludeTools` will be disabled for the entire conversation context and will affect all subsequent queries in the current session.
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. Note that this differs from the MCP server `excludeTools` functionality, which can be listed in the MCP server config.
When Qwen Code starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.

View File

@@ -106,10 +106,7 @@ Subagents are configured using Markdown files with YAML frontmatter. This format
---
name: agent-name
description: Brief description of when and how to use this agent
tools:
- tool1
- tool2
- tool3 # Optional
tools: tool1, tool2, tool3 # Optional
---
System prompt content goes here.
@@ -170,11 +167,7 @@ Perfect for comprehensive test creation and test-driven development.
---
name: testing-expert
description: Writes comprehensive unit tests, integration tests, and handles test automation with best practices
tools:
- read_file
- write_file
- read_many_files
- run_shell_command
tools: read_file, write_file, read_many_files, run_shell_command
---
You are a testing specialist focused on creating high-quality, maintainable tests.
@@ -214,11 +207,7 @@ Specialized in creating clear, comprehensive documentation.
---
name: documentation-writer
description: Creates comprehensive documentation, README files, API docs, and user guides
tools:
- read_file
- write_file
- read_many_files
- web_search
tools: read_file, write_file, read_many_files, web_search
---
You are a technical documentation specialist for ${project_name}.
@@ -267,9 +256,7 @@ Focused on code quality, security, and best practices.
---
name: code-reviewer
description: Reviews code for best practices, security issues, performance, and maintainability
tools:
- read_file
- read_many_files
tools: read_file, read_many_files
---
You are an experienced code reviewer focused on quality, security, and maintainability.
@@ -311,11 +298,7 @@ Optimized for React development, hooks, and component patterns.
---
name: react-specialist
description: Expert in React development, hooks, component patterns, and modern React best practices
tools:
- read_file
- write_file
- read_many_files
- run_shell_command
tools: read_file, write_file, read_many_files, run_shell_command
---
You are a React specialist with deep expertise in modern React development.
@@ -356,11 +339,7 @@ Specialized in Python development, frameworks, and best practices.
---
name: python-expert
description: Expert in Python development, frameworks, testing, and Python-specific best practices
tools:
- read_file
- write_file
- read_many_files
- run_shell_command
tools: read_file, write_file, read_many_files, run_shell_command
---
You are a Python expert with deep knowledge of the Python ecosystem.

View File

@@ -14,13 +14,6 @@ This guide provides solutions to common issues and debugging tips, including top
- **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`
- **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:
- Open `~/.qwen/settings.json` (or `./.qwen/settings.json` for project-specific settings)
- Remove the `security.auth.selectedType` field
- Restart the CLI to allow it to prompt for authentication again
## Frequently asked questions (FAQs)
- **Q: How do I update Qwen Code to the latest version?**

View File

@@ -4,12 +4,12 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local
**Note:** All file system tools operate within a `rootDirectory` (usually the current working directory where you launched the CLI) for security. Paths that you provide to these tools are generally expected to be absolute or are resolved relative to this root directory.
## 1. `list_directory` (ListFiles)
## 1. `list_directory` (ReadFolder)
`list_directory` lists the names of files and subdirectories directly within a specified directory path. It can optionally ignore entries matching provided glob patterns.
- **Tool name:** `list_directory`
- **Display name:** ListFiles
- **Display name:** ReadFolder
- **File:** `ls.ts`
- **Parameters:**
- `path` (string, required): The absolute path to the directory to list.
@@ -59,80 +59,86 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local
- **Output (`llmContent`):** A success message, e.g., `Successfully overwrote file: /path/to/your/file.txt` or `Successfully created and wrote to new file: /path/to/new/file.txt`.
- **Confirmation:** Yes. Shows a diff of changes and asks for user approval before writing.
## 4. `glob` (Glob)
## 4. `glob` (FindFiles)
`glob` finds files matching specific glob patterns (e.g., `src/**/*.ts`, `*.md`), returning absolute paths sorted by modification time (newest first).
- **Tool name:** `glob`
- **Display name:** Glob
- **Display name:** FindFiles
- **File:** `glob.ts`
- **Parameters:**
- `pattern` (string, required): The glob pattern to match against (e.g., `"*.py"`, `"src/**/*.js"`).
- `path` (string, optional): The directory to search in. If not specified, the current working directory will be used.
- `path` (string, optional): The absolute path to the directory to search within. If omitted, searches the tool's root directory.
- `case_sensitive` (boolean, optional): Whether the search should be case-sensitive. Defaults to `false`.
- `respect_git_ignore` (boolean, optional): Whether to respect .gitignore patterns when finding files. Defaults to `true`.
- **Behavior:**
- Searches for files matching the glob pattern within the specified directory.
- Returns a list of absolute paths, sorted with the most recently modified files first.
- Respects .gitignore and .qwenignore patterns by default.
- Limits results to 100 files to prevent context overflow.
- **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within /path/to/search/dir, sorted by modification time (newest first):\n---\n/path/to/file1.ts\n/path/to/subdir/file2.ts\n---\n[95 files truncated] ...`
- Ignores common nuisance directories like `node_modules` and `.git` by default.
- **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...`
- **Confirmation:** No.
## 5. `grep_search` (Grep)
## 5. `search_file_content` (SearchText)
`grep_search` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.
`search_file_content` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.
- **Tool name:** `grep_search`
- **Display name:** Grep
- **File:** `ripGrep.ts` (with `grep.ts` as fallback)
- **Tool name:** `search_file_content`
- **Display name:** SearchText
- **File:** `grep.ts`
- **Parameters:**
- `pattern` (string, required): The regular expression pattern to search for in file contents (e.g., `"function\\s+myFunction"`, `"log.*Error"`).
- `path` (string, optional): File or directory to search in. Defaults to current working directory.
- `glob` (string, optional): Glob pattern to filter files (e.g. `"*.js"`, `"src/**/*.{ts,tsx}"`).
- `limit` (number, optional): Limit output to first N matching lines. Optional - shows all matches if not specified.
- `pattern` (string, required): The regular expression (regex) to search for (e.g., `"function\s+myFunction"`).
- `path` (string, optional): The absolute path to the directory to search within. Defaults to the current working directory.
- `include` (string, optional): A glob pattern to filter which files are searched (e.g., `"*.js"`, `"src/**/*.{ts,tsx}"`). If omitted, searches most files (respecting common ignores).
- `maxResults` (number, optional): Maximum number of matches to return to prevent context overflow (default: 20, max: 100). Use lower values for broad searches, higher for specific searches.
- **Behavior:**
- Uses ripgrep for fast search when available; otherwise falls back to a JavaScript-based search implementation.
- Returns matching lines with file paths and line numbers.
- Case-insensitive by default.
- Respects .gitignore and .qwenignore patterns.
- Limits output to prevent context overflow.
- Uses `git grep` if available in a Git repository for speed; otherwise, falls back to system `grep` or a JavaScript-based search.
- Returns a list of matching lines, each prefixed with its file path (relative to the search directory) and line number.
- Limits results to a maximum of 20 matches by default to prevent context overflow. When results are truncated, shows a clear warning with guidance on refining searches.
- **Output (`llmContent`):** A formatted string of matches, e.g.:
```
Found 3 matches for pattern "myFunction" in path "." (filter: "*.ts"):
---
src/utils.ts:15:export function myFunction() {
src/utils.ts:22: myFunction.call();
src/index.ts:5:import { myFunction } from './utils';
File: src/utils.ts
L15: export function myFunction() {
L22: myFunction.call();
---
File: src/index.ts
L5: import { myFunction } from './utils';
---
[0 lines truncated] ...
WARNING: Results truncated to prevent context overflow. To see more results:
- Use a more specific pattern to reduce matches
- Add file filters with the 'include' parameter (e.g., "*.js", "src/**")
- Specify a narrower 'path' to search in a subdirectory
- Increase 'maxResults' parameter if you need more matches (current: 20)
```
- **Confirmation:** No.
### `grep_search` examples
### `search_file_content` examples
Search for a pattern with default result limiting:
```
grep_search(pattern="function\\s+myFunction", path="src")
search_file_content(pattern="function\s+myFunction", path="src")
```
Search for a pattern with custom result limiting:
```
grep_search(pattern="function", path="src", limit=50)
search_file_content(pattern="function", path="src", maxResults=50)
```
Search for a pattern with file filtering and custom result limiting:
```
grep_search(pattern="function", glob="*.js", limit=10)
search_file_content(pattern="function", include="*.js", maxResults=10)
```
## 6. `edit` (Edit)
`edit` replaces text within a file. By default it requires `old_string` to match a single unique location; set `replace_all` to `true` when you intentionally want to change every occurrence. This tool is designed for precise, targeted changes and requires significant context around the `old_string` to ensure it modifies the correct location.
`edit` replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when `expected_replacements` is specified. This tool is designed for precise, targeted changes and requires significant context around the `old_string` to ensure it modifies the correct location.
- **Tool name:** `edit`
- **Display name:** Edit
@@ -144,12 +150,12 @@ grep_search(pattern="function", glob="*.js", limit=10)
**CRITICAL:** This string must uniquely identify the single instance to change. It should include at least 3 lines of context _before_ and _after_ the target text, matching whitespace and indentation precisely. If `old_string` is empty, the tool attempts to create a new file at `file_path` with `new_string` as content.
- `new_string` (string, required): The exact literal text to replace `old_string` with.
- `replace_all` (boolean, optional): Replace all occurrences of `old_string`. Defaults to `false`.
- `expected_replacements` (number, optional): The number of occurrences to replace. Defaults to `1`.
- **Behavior:**
- If `old_string` is empty and `file_path` does not exist, creates a new file with `new_string` as content.
- If `old_string` is provided, it reads the `file_path` and attempts to find exactly one occurrence unless `replace_all` is true.
- If the match is unique (or `replace_all` is true), it replaces the text with `new_string`.
- If `old_string` is provided, it reads the `file_path` and attempts to find exactly one occurrence of `old_string`.
- If one occurrence is found, it replaces it with `new_string`.
- **Enhanced Reliability (Multi-Stage Edit Correction):** To significantly improve the success rate of edits, especially when the model-provided `old_string` might not be perfectly precise, the tool incorporates a multi-stage edit correction mechanism.
- If the initial `old_string` isn't found or matches multiple locations, the tool can leverage the Qwen model to iteratively refine `old_string` (and potentially `new_string`).
- This self-correction process attempts to identify the unique segment the model intended to modify, making the `edit` operation more robust even with slightly imperfect initial context.
@@ -158,10 +164,10 @@ grep_search(pattern="function", glob="*.js", limit=10)
- `old_string` is not empty, but the `file_path` does not exist.
- `old_string` is empty, but the `file_path` already exists.
- `old_string` is not found in the file after attempts to correct it.
- `old_string` is found multiple times, `replace_all` is false, and the self-correction mechanism cannot resolve it to a single, unambiguous match.
- `old_string` is found multiple times, and the self-correction mechanism cannot resolve it to a single, unambiguous match.
- **Output (`llmContent`):**
- On success: `Successfully modified file: /path/to/file.txt (1 replacements).` or `Created new file: /path/to/new_file.txt with provided content.`
- On failure: An error message explaining the reason (e.g., `Failed to edit, 0 occurrences found...`, `Failed to edit because the text matches multiple locations...`).
- On failure: An error message explaining the reason (e.g., `Failed to edit, 0 occurrences found...`, `Failed to edit, expected 1 occurrences but found 2...`).
- **Confirmation:** Yes. Shows a diff of the proposed changes and asks for user approval before writing to the file.
These file system tools provide a foundation for Qwen Code to understand and interact with your local project context.

View File

@@ -1,186 +1,43 @@
# Web Search Tool (`web_search`)
This document describes the `web_search` tool for performing web searches using multiple providers.
This document describes the `web_search` tool.
## Description
Use `web_search` to perform a web search and get information from the internet. The tool supports multiple search providers and returns a concise answer with source citations when available.
### Supported Providers
1. **DashScope** (Official, Free) - Automatically available for Qwen OAuth users (200 requests/minute, 2000 requests/day)
2. **Tavily** - High-quality search API with built-in answer generation
3. **Google Custom Search** - Google's Custom Search JSON API
Use `web_search` to perform a web search using the Tavily API. The tool returns a concise answer with sources when possible.
### Arguments
`web_search` takes two arguments:
`web_search` takes one argument:
- `query` (string, required): The search query
- `provider` (string, optional): Specific provider to use ("dashscope", "tavily", "google")
- If not specified, uses the default provider from configuration
- `query` (string, required): The search query.
## Configuration
## How to use `web_search`
### Method 1: Settings File (Recommended)
`web_search` calls the Tavily API directly. You must configure the `TAVILY_API_KEY` through one of the following methods:
Add to your `settings.json`:
1. **Settings file**: Add `"tavilyApiKey": "your-key-here"` to your `settings.json`
2. **Environment variable**: Set `TAVILY_API_KEY` in your environment or `.env` file
3. **Command line**: Use `--tavily-api-key your-key-here` when running the CLI
```json
{
"webSearch": {
"provider": [
{ "type": "dashscope" },
{ "type": "tavily", "apiKey": "tvly-xxxxx" },
{
"type": "google",
"apiKey": "your-google-api-key",
"searchEngineId": "your-search-engine-id"
}
],
"default": "dashscope"
}
}
```
If the key is not configured, the tool will be disabled and skipped.
**Notes:**
- DashScope doesn't require an API key (official, free service)
- **Qwen OAuth users:** DashScope is automatically added to your provider list, even if not explicitly configured
- Configure additional providers (Tavily, Google) if you want to use them alongside DashScope
- Set `default` to specify which provider to use by default (if not set, priority order: Tavily > Google > DashScope)
### Method 2: Environment Variables
Set environment variables in your shell or `.env` file:
```bash
# Tavily
export TAVILY_API_KEY="tvly-xxxxx"
# Google
export GOOGLE_API_KEY="your-api-key"
export GOOGLE_SEARCH_ENGINE_ID="your-engine-id"
```
### Method 3: Command Line Arguments
Pass API keys when running Qwen Code:
```bash
# Tavily
qwen --tavily-api-key tvly-xxxxx
# Google
qwen --google-api-key your-key --google-search-engine-id your-id
# Specify default provider
qwen --web-search-default tavily
```
### Backward Compatibility (Deprecated)
⚠️ **DEPRECATED:** The legacy `tavilyApiKey` configuration is still supported for backward compatibility but is deprecated:
```json
{
"advanced": {
"tavilyApiKey": "tvly-xxxxx" // ⚠️ Deprecated
}
}
```
**Important:** This configuration is deprecated and will be removed in a future version. Please migrate to the new `webSearch` configuration format shown above. The old configuration will automatically configure Tavily as a provider, but we strongly recommend updating your configuration.
## Disabling Web Search
If you want to disable the web search functionality, you can exclude the `web_search` tool in your `settings.json`:
```json
{
"tools": {
"exclude": ["web_search"]
}
}
```
**Note:** This setting requires a restart of Qwen Code to take effect. Once disabled, the `web_search` tool will not be available to the model, even if web search providers are configured.
## Usage Examples
### Basic search (using default provider)
Usage:
```
web_search(query="latest advancements in AI")
web_search(query="Your query goes here.")
```
### Search with specific provider
## `web_search` examples
Get information on a topic:
```
web_search(query="latest advancements in AI", provider="tavily")
web_search(query="latest advancements in AI-powered code generation")
```
### Real-world examples
## Important notes
```
web_search(query="weather in San Francisco today")
web_search(query="latest Node.js LTS version", provider="google")
web_search(query="best practices for React 19", provider="dashscope")
```
## Provider Details
### DashScope (Official)
- **Cost:** Free
- **Authentication:** Automatically available when using Qwen OAuth authentication
- **Configuration:** No API key required, automatically added to provider list for Qwen OAuth users
- **Quota:** 200 requests/minute, 2000 requests/day
- **Best for:** General queries, always available as fallback for Qwen OAuth users
- **Auto-registration:** If you're using Qwen OAuth, DashScope is automatically added to your provider list even if you don't configure it explicitly
### Tavily
- **Cost:** Requires API key (paid service with free tier)
- **Sign up:** https://tavily.com
- **Features:** High-quality results with AI-generated answers
- **Best for:** Research, comprehensive answers with citations
### Google Custom Search
- **Cost:** Free tier available (100 queries/day)
- **Setup:**
1. Enable Custom Search API in Google Cloud Console
2. Create a Custom Search Engine at https://programmablesearchengine.google.com
- **Features:** Google's search quality
- **Best for:** Specific, factual queries
## Important Notes
- **Response format:** Returns a concise answer with numbered source citations
- **Citations:** Source links are appended as a numbered list: [1], [2], etc.
- **Multiple providers:** If one provider fails, manually specify another using the `provider` parameter
- **DashScope availability:** Automatically available for Qwen OAuth users, no configuration needed
- **Default provider selection:** The system automatically selects a default provider based on availability:
1. Your explicit `default` configuration (highest priority)
2. CLI argument `--web-search-default`
3. First available provider by priority: Tavily > Google > DashScope
## Troubleshooting
**Tool not available?**
- **For Qwen OAuth users:** The tool is automatically registered with DashScope provider, no configuration needed
- **For other authentication types:** Ensure at least one provider (Tavily or Google) is configured
- For Tavily/Google: Verify your API keys are correct
**Provider-specific errors?**
- Use the `provider` parameter to try a different search provider
- Check your API quotas and rate limits
- Verify API keys are properly set in configuration
**Need help?**
- Check your configuration: Run `qwen` and use the settings dialog
- View your current settings in `~/.qwen-code/settings.json` (macOS/Linux) or `%USERPROFILE%\.qwen-code\settings.json` (Windows)
- **Response returned:** The `web_search` tool returns a concise answer when available, with a list of source links.
- **Citations:** Source links are appended as a numbered list.
- **API key:** Configure `TAVILY_API_KEY` via settings.json, environment variables, .env files, or command line arguments. If not configured, the tool is not registered.

View File

@@ -7,7 +7,7 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { writeFileSync, rmSync } from 'node:fs';
import { writeFileSync } from 'node:fs';
let esbuild;
try {
@@ -22,9 +22,6 @@ const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url);
const pkg = require(path.resolve(__dirname, 'package.json'));
// Clean dist directory (cross-platform)
rmSync(path.resolve(__dirname, 'dist'), { recursive: true, force: true });
const external = [
'@lydell/node-pty',
'node-pty',
@@ -33,24 +30,16 @@ const external = [
'@lydell/node-pty-linux-x64',
'@lydell/node-pty-win32-arm64',
'@lydell/node-pty-win32-x64',
'tiktoken',
];
esbuild
.build({
entryPoints: ['packages/cli/index.ts'],
bundle: true,
outfile: 'dist/cli.js',
outfile: 'bundle/gemini.js',
platform: 'node',
format: 'esm',
target: 'node20',
external,
packages: 'bundle',
inject: [path.resolve(__dirname, 'scripts/esbuild-shims.js')],
banner: {
js: `// Force strict mode and setup for ESM
"use strict";`,
},
alias: {
'is-in-ci': path.resolve(
__dirname,
@@ -59,20 +48,17 @@ esbuild
},
define: {
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
// Make global available for compatibility
global: 'globalThis',
},
banner: {
js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
},
loader: { '.node': 'file' },
metafile: true,
write: true,
keepNames: true,
})
.then(({ metafile }) => {
if (process.env.DEV === 'true') {
writeFileSync('./dist/esbuild.json', JSON.stringify(metafile, null, 2));
writeFileSync('./bundle/esbuild.json', JSON.stringify(metafile, null, 2));
}
})
.catch((error) => {
console.error('esbuild build failed:', error);
process.exitCode = 1;
});
.catch(() => process.exit(1));

View File

@@ -12,12 +12,24 @@ import prettierConfig from 'eslint-config-prettier';
import importPlugin from 'eslint-plugin-import';
import vitest from '@vitest/eslint-plugin';
import globals from 'globals';
import licenseHeader from 'eslint-plugin-license-header';
import path from 'node:path';
import url from 'node:url';
// --- ESM way to get __dirname ---
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// --- ---
// Determine the monorepo root (assuming eslint.config.js is at the root)
const projectRoot = __dirname;
export default tseslint.config(
{
// Global ignores
ignores: [
'node_modules/*',
'eslint.config.js',
'packages/**/dist/**',
'bundle/**',
'package/bundle/**',
@@ -210,21 +222,6 @@ export default tseslint.config(
'@typescript-eslint/no-require-imports': 'off',
},
},
// extra settings for core package scripts
{
files: ['packages/core/scripts/**/*.js'],
languageOptions: {
globals: {
...globals.node,
process: 'readonly',
console: 'readonly',
},
},
rules: {
'no-restricted-syntax': 'off',
'@typescript-eslint/no-require-imports': 'off',
},
},
// Prettier config must be last
prettierConfig,
// extra settings for scripts that we run directly with node

View File

@@ -21,21 +21,23 @@ describe('Interactive Mode', () => {
it.skipIf(process.platform === 'win32')(
'should trigger chat compression with /compress command',
async () => {
await rig.setup('interactive-compress-test', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
await rig.setup('interactive-compress-test');
const { ptyProcess } = rig.runInteractive();
let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000);
expect(
@@ -66,43 +68,49 @@ describe('Interactive Mode', () => {
},
);
it.skip('should handle compression failure on token inflation', async () => {
await rig.setup('interactive-compress-test', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
it.skipIf(process.platform === 'win32')(
'should handle compression failure on token inflation',
async () => {
await rig.setup('interactive-compress-test');
const { ptyProcess } = rig.runInteractive();
const { ptyProcess } = rig.runInteractive();
let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 25000);
expect(isReady, 'CLI did not start up in interactive mode correctly').toBe(
true,
);
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
await type(ptyProcess, '/compress');
await new Promise((resolve) => setTimeout(resolve, 1000));
await type(ptyProcess, '\r');
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
const foundEvent = await rig.waitForTelemetryEvent(
'chat_compression',
90000,
);
expect(foundEvent).toBe(true);
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 25000);
expect(
isReady,
'CLI did not start up in interactive mode correctly',
).toBe(true);
const compressionFailed = await rig.waitForText(
'Nothing to compress.',
25000,
);
await type(ptyProcess, '/compress');
await new Promise((resolve) => setTimeout(resolve, 100));
await type(ptyProcess, '\r');
expect(compressionFailed).toBe(true);
});
const foundEvent = await rig.waitForTelemetryEvent(
'chat_compression',
90000,
);
expect(foundEvent).toBe(true);
const compressionFailed = await rig.waitForText(
'compression was not beneficial',
25000,
);
expect(compressionFailed).toBe(true);
},
);
});

View File

@@ -92,7 +92,7 @@ describe('edit', () => {
expect(newFileContent).toBe(expectedContent);
});
it.skip('should fail safely when old_string is not found', async () => {
it('should fail safely when old_string is not found', async () => {
const rig = new TestRig();
await rig.setup('should fail safely when old_string is not found');
const fileName = 'no_match.txt';

View File

@@ -19,22 +19,24 @@ describe('Interactive file system', () => {
});
it.skipIf(process.platform === 'win32')(
'should perform a read-then-write sequence in interactive mode',
'should perform a read-then-write sequence',
async () => {
const fileName = 'version.txt';
await rig.setup('interactive-read-then-write', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
await rig.setup('interactive-read-then-write');
rig.createFile(fileName, '1.0.0');
const { ptyProcess } = rig.runInteractive();
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000);
expect(

View File

@@ -36,10 +36,10 @@ describe('JSON output', () => {
});
it('should return a JSON error for enforced auth mismatch before running', async () => {
process.env['OPENAI_API_KEY'] = 'test-key';
process.env['GOOGLE_GENAI_USE_GCA'] = 'true';
await rig.setup('json-output-auth-mismatch', {
settings: {
security: { auth: { enforcedType: 'qwen-oauth' } },
security: { auth: { enforcedType: 'gemini-api-key' } },
},
});
@@ -50,7 +50,7 @@ describe('JSON output', () => {
} catch (e) {
thrown = e as Error;
} finally {
delete process.env['OPENAI_API_KEY'];
delete process.env['GOOGLE_GENAI_USE_GCA'];
}
expect(thrown).toBeDefined();
@@ -80,8 +80,10 @@ describe('JSON output', () => {
expect(payload.error.type).toBe('Error');
expect(payload.error.code).toBe(1);
expect(payload.error.message).toContain(
'configured auth type is qwen-oauth',
'configured auth type is gemini-api-key',
);
expect(payload.error.message).toContain(
'current auth type is oauth-personal',
);
expect(payload.error.message).toContain('current auth type is openai');
});
});

View File

@@ -9,6 +9,7 @@ import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { env } from 'node:process';
import { DEFAULT_QWEN_MODEL } from '../packages/core/src/config/models.js';
import fs from 'node:fs';
import { EOL } from 'node:os';
import * as pty from '@lydell/node-pty';
@@ -147,7 +148,7 @@ export class TestRig {
_interactiveOutput = '';
constructor() {
this.bundlePath = join(__dirname, '..', 'dist/cli.js');
this.bundlePath = join(__dirname, '..', 'bundle/gemini.js');
this.testDir = null;
}
@@ -181,6 +182,7 @@ export class TestRig {
otlpEndpoint: '',
outfile: telemetryPath,
},
model: DEFAULT_QWEN_MODEL,
sandbox: env.GEMINI_SANDBOX !== 'false' ? env.GEMINI_SANDBOX : false,
...options.settings, // Allow tests to override/add settings
};

View File

@@ -12,12 +12,13 @@ describe('todo_write', () => {
const rig = new TestRig();
await rig.setup('should be able to create and manage a todo list');
const prompt = `Please create a todo list with these three simple tasks:
1. Buy milk
2. Walk the dog
3. Read a book
const prompt = `I want to implement a new feature to track user preferences. Here are the tasks:
1. Create a user preferences model
2. Add API endpoints for preferences
3. Implement frontend components
4. Write tests for the new functionality
Use the todo_write tool to create this list.`;
Please create a todo list for these tasks.`;
const result = await rig.run(prompt);
@@ -49,21 +50,83 @@ Use the todo_write tool to create this list.`;
expect(todoArgs.todos).toBeDefined();
expect(Array.isArray(todoArgs.todos)).toBe(true);
expect(todoArgs.todos.length).toBeGreaterThanOrEqual(3);
expect(todoArgs.todos.length).toBe(4);
// Check that all todos have the correct structure
for (const todo of todoArgs.todos) {
expect(todo.id).toBeDefined();
expect(todo.content).toBeDefined();
expect(['pending', 'in_progress', 'completed', 'cancelled']).toContain(
todo.status,
);
expect(['pending', 'in_progress', 'completed']).toContain(todo.status);
}
// Log success info if verbose
if (process.env['VERBOSE'] === 'true') {
console.log('Todo list created successfully');
console.log(`Created ${todoArgs.todos.length} todos`);
}
});
it('should be able to update todo status', async () => {
const rig = new TestRig();
await rig.setup('should be able to update todo status');
// First create a todo list
const initialPrompt = `Create a todo list with these tasks:
1. Set up project structure
2. Implement authentication
3. Add database migrations`;
await rig.run(initialPrompt);
await rig.waitForToolCall('todo_write');
// Now update the todo list by marking one as in progress
const updatePrompt = `I've started working on implementing authentication. Please update the todo list to reflect that.`;
const result = await rig.run(updatePrompt);
const foundToolCall = await rig.waitForToolCall('todo_write');
// Add debugging information
if (!foundToolCall) {
printDebugInfo(rig, result);
}
expect(
foundToolCall,
'Expected to find a todo_write tool call',
).toBeTruthy();
// Validate model output - will throw if no output
validateModelOutput(result, null, 'Todo update test');
// Check that the tool was called with updated parameters
const toolLogs = rig.readToolLogs();
const todoWriteCalls = toolLogs.filter(
(t) => t.toolRequest.name === 'todo_write',
);
expect(todoWriteCalls.length).toBeGreaterThan(0);
// Parse the arguments to verify the update
const todoArgs = JSON.parse(
todoWriteCalls[todoWriteCalls.length - 1].toolRequest.args,
);
expect(todoArgs.todos).toBeDefined();
expect(Array.isArray(todoArgs.todos)).toBe(true);
// The model might create a new list with just the task it's working on
// or it might update the existing list. Let's check that we have at least one todo
expect(todoArgs.todos.length).toBeGreaterThanOrEqual(1);
// Check that all todos have the correct structure
for (const todo of todoArgs.todos) {
expect(todo.id).toBeDefined();
expect(todo.content).toBeDefined();
expect(['pending', 'in_progress', 'completed']).toContain(todo.status);
}
// Log success info if verbose
if (process.env['VERBOSE'] === 'true') {
console.log('Todo list updated successfully');
}
});
});

View File

@@ -9,53 +9,14 @@ import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
describe('web_search', () => {
it('should be able to search the web', async () => {
// Check if any web search provider is available
const hasTavilyKey = !!process.env['TAVILY_API_KEY'];
const hasGoogleKey =
!!process.env['GOOGLE_API_KEY'] &&
!!process.env['GOOGLE_SEARCH_ENGINE_ID'];
// Skip if no provider is configured
// Note: DashScope provider is automatically available for Qwen OAuth users,
// but we can't easily detect that in tests without actual OAuth credentials
if (!hasTavilyKey && !hasGoogleKey) {
console.warn(
'Skipping web search test: No web search provider configured. ' +
'Set TAVILY_API_KEY or GOOGLE_API_KEY+GOOGLE_SEARCH_ENGINE_ID environment variables.',
);
// Skip if Tavily key is not configured
if (!process.env['TAVILY_API_KEY']) {
console.warn('Skipping web search test: TAVILY_API_KEY not set');
return;
}
const rig = new TestRig();
// Configure web search in settings if provider keys are available
const webSearchSettings: Record<string, unknown> = {};
const providers: Array<{
type: string;
apiKey?: string;
searchEngineId?: string;
}> = [];
if (hasTavilyKey) {
providers.push({ type: 'tavily', apiKey: process.env['TAVILY_API_KEY'] });
}
if (hasGoogleKey) {
providers.push({
type: 'google',
apiKey: process.env['GOOGLE_API_KEY'],
searchEngineId: process.env['GOOGLE_SEARCH_ENGINE_ID'],
});
}
if (providers.length > 0) {
webSearchSettings.webSearch = {
provider: providers,
default: providers[0]?.type,
};
}
await rig.setup('should be able to search the web', {
settings: webSearchSettings,
});
await rig.setup('should be able to search the web');
let result;
try {

486
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.2.2",
"version": "0.0.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.2.2",
"version": "0.0.14",
"workspaces": [
"packages/*"
],
@@ -15,7 +15,7 @@
"simple-git": "^3.28.0"
},
"bin": {
"qwen": "dist/cli.js"
"qwen": "bundle/gemini.js"
},
"devDependencies": {
"@types/marked": "^5.0.2",
@@ -1501,6 +1501,28 @@
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@joshua.litt/get-ripgrep": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@joshua.litt/get-ripgrep/-/get-ripgrep-0.0.2.tgz",
"integrity": "sha512-cSHA+H+HEkOXeiCxrNvGj/pgv2Y0bfp4GbH3R87zr7Vob2pDUZV3BkUL9ucHMoDFID4GteSy5z5niN/lF9QeuQ==",
"dependencies": {
"@lvce-editor/verror": "^1.6.0",
"execa": "^9.5.2",
"extract-zip": "^2.0.1",
"fs-extra": "^11.3.0",
"got": "^14.4.5",
"path-exists": "^5.0.0",
"xdg-basedir": "^5.1.0"
}
},
"node_modules/@joshua.litt/get-ripgrep/node_modules/path-exists": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
"integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
@@ -1698,6 +1720,12 @@
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
"license": "MIT"
},
"node_modules/@lvce-editor/verror": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@lvce-editor/verror/-/verror-1.7.0.tgz",
"integrity": "sha512-+LGuAEIC2L7pbvkyAQVWM2Go0dAy+UWEui28g07zNtZsCBhm+gusBK8PNwLJLV5Jay+TyUYuwLIbJdjLLzqEBg==",
"license": "MIT"
},
"node_modules/@lydell/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz",
@@ -3056,6 +3084,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@sec-ant/readable-stream": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
"license": "MIT"
},
"node_modules/@secretlint/config-creator": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz",
@@ -3274,6 +3308,42 @@
"dev": true,
"license": "MIT"
},
"node_modules/@sindresorhus/is": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.0.2.tgz",
"integrity": "sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
"integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@szmarczak/http-timer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
"integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
"license": "MIT",
"dependencies": {
"defer-to-connect": "^2.0.1"
},
"engines": {
"node": ">=14.16"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -3609,6 +3679,12 @@
"integrity": "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==",
"license": "MIT"
},
"node_modules/@types/http-cache-semantics": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
"integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==",
"license": "MIT"
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -5609,6 +5685,33 @@
"node": ">=8"
}
},
"node_modules/cacheable-lookup": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
"integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==",
"license": "MIT",
"engines": {
"node": ">=14.16"
}
},
"node_modules/cacheable-request": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-12.0.1.tgz",
"integrity": "sha512-Yo9wGIQUaAfIbk+qY0X4cDQgCosecfBe3V9NSyeY4qPC2SAkbCS4Xj79VP8WOzitpJUZKc/wsRCYF5ariDIwkg==",
"license": "MIT",
"dependencies": {
"@types/http-cache-semantics": "^4.0.4",
"get-stream": "^9.0.1",
"http-cache-semantics": "^4.1.1",
"keyv": "^4.5.4",
"mimic-response": "^4.0.0",
"normalize-url": "^8.0.1",
"responselike": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -6529,9 +6632,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"mimic-response": "^3.1.0"
},
@@ -6546,9 +6647,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
},
@@ -6619,6 +6718,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -7697,6 +7805,44 @@
"node": ">=20.0.0"
}
},
"node_modules/execa": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz",
"integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==",
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
"cross-spawn": "^7.0.6",
"figures": "^6.1.0",
"get-stream": "^9.0.0",
"human-signals": "^8.0.1",
"is-plain-obj": "^4.1.0",
"is-stream": "^4.0.1",
"npm-run-path": "^6.0.0",
"pretty-ms": "^9.2.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^4.0.0",
"yoctocolors": "^2.1.1"
},
"engines": {
"node": "^18.19.0 || >=20.5.0"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/execa/node_modules/is-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
"integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -7941,6 +8087,21 @@
"pend": "~1.2.0"
}
},
"node_modules/figures": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
"integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==",
"license": "MIT",
"dependencies": {
"is-unicode-supported": "^2.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -8112,6 +8273,15 @@
"node": ">= 6"
}
},
"node_modules/form-data-encoder": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz",
"integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==",
"license": "MIT",
"engines": {
"node": ">= 18"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
@@ -8161,7 +8331,6 @@
"version": "11.3.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz",
"integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
@@ -8176,7 +8345,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
@@ -8331,6 +8499,34 @@
"node": ">= 0.4"
}
},
"node_modules/get-stream": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
"integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
"license": "MIT",
"dependencies": {
"@sec-ant/readable-stream": "^0.4.1",
"is-stream": "^4.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-stream/node_modules/is-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
"integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-symbol-description": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
@@ -8611,6 +8807,43 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/got": {
"version": "14.4.8",
"resolved": "https://registry.npmjs.org/got/-/got-14.4.8.tgz",
"integrity": "sha512-vxwU4HuR0BIl+zcT1LYrgBjM+IJjNElOjCzs0aPgHorQyr/V6H6Y73Sn3r3FOlUffvWD+Q5jtRuGWaXkU8Jbhg==",
"license": "MIT",
"dependencies": {
"@sindresorhus/is": "^7.0.1",
"@szmarczak/http-timer": "^5.0.1",
"cacheable-lookup": "^7.0.0",
"cacheable-request": "^12.0.1",
"decompress-response": "^6.0.0",
"form-data-encoder": "^4.0.2",
"http2-wrapper": "^2.2.1",
"lowercase-keys": "^3.0.0",
"p-cancelable": "^4.0.1",
"responselike": "^3.0.0",
"type-fest": "^4.26.1"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sindresorhus/got?sponsor=1"
}
},
"node_modules/got/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -8843,6 +9076,12 @@
"entities": "^4.4.0"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
"license": "BSD-2-Clause"
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -8882,6 +9121,19 @@
"node": ">= 14"
}
},
"node_modules/http2-wrapper": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz",
"integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==",
"license": "MIT",
"dependencies": {
"quick-lru": "^5.1.1",
"resolve-alpn": "^1.2.0"
},
"engines": {
"node": ">=10.19.0"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -8895,6 +9147,15 @@
"node": ">= 14"
}
},
"node_modules/human-signals": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
"integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
@@ -9706,6 +9967,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
"integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -9830,6 +10103,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-unicode-supported": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-weakmap": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -10107,7 +10392,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true,
"license": "MIT"
},
"node_modules/json-parse-better-errors": {
@@ -10164,7 +10448,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
@@ -10177,7 +10460,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
@@ -10292,7 +10574,6 @@
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
@@ -10772,6 +11053,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/lowercase-keys": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
"integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lowlight": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
@@ -11012,6 +11305,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mimic-response": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz",
"integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -11352,6 +11657,18 @@
"node": ">=0.10.0"
}
},
"node_modules/normalize-url": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.2.tgz",
"integrity": "sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==",
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-normalize-package-bin": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz",
@@ -11633,6 +11950,46 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/npm-run-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
"integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==",
"license": "MIT",
"dependencies": {
"path-key": "^4.0.0",
"unicorn-magic": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-run-path/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-run-path/node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -11898,6 +12255,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/p-cancelable": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz",
"integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==",
"license": "MIT",
"engines": {
"node": ">=14.16"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -12009,6 +12375,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-ms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
"integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-semver": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz",
@@ -12395,6 +12773,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/pretty-ms": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz",
"integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==",
"license": "MIT",
"dependencies": {
"parse-ms": "^4.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
@@ -12574,6 +12967,18 @@
],
"license": "MIT"
},
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qwen-code-vscode-ide-companion": {
"resolved": "packages/vscode-ide-companion",
"link": true
@@ -13026,6 +13431,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-alpn": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -13046,6 +13457,21 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/responselike": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz",
"integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==",
"license": "MIT",
"dependencies": {
"lowercase-keys": "^3.0.0"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/restore-cursor": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
@@ -14081,6 +14507,18 @@
"node": ">=4"
}
},
"node_modules/strip-final-newline": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
"integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -15928,6 +16366,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yoctocolors": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz",
"integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yoctocolors-cjs": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz",
@@ -16024,7 +16474,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.2.2",
"version": "0.0.14",
"dependencies": {
"@google/genai": "1.16.0",
"@iarna/toml": "^2.2.5",
@@ -16139,10 +16589,10 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.2.2",
"hasInstallScript": true,
"version": "0.0.14",
"dependencies": {
"@google/genai": "1.16.0",
"@joshua.litt/get-ripgrep": "^0.0.2",
"@modelcontextprotocol/sdk": "^1.11.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
@@ -16278,7 +16728,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.2.2",
"version": "0.0.14",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -16290,7 +16740,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.2.2",
"version": "0.0.14",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.2.2",
"version": "0.0.14",
"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.2.2"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.14"
},
"scripts": {
"start": "cross-env node scripts/start.js",
@@ -63,10 +63,10 @@
}
},
"bin": {
"qwen": "dist/cli.js"
"qwen": "bundle/gemini.js"
},
"files": [
"dist/",
"bundle/",
"README.md",
"LICENSE"
],

View File

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

View File

@@ -18,26 +18,60 @@ vi.mock('./settings.js', () => ({
describe('validateAuthMethod', () => {
beforeEach(() => {
vi.resetModules();
vi.stubEnv('GEMINI_API_KEY', undefined);
vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined);
vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined);
vi.stubEnv('GOOGLE_API_KEY', undefined);
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should return null for USE_OPENAI', () => {
process.env['OPENAI_API_KEY'] = 'fake-key';
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
it('should return null for LOGIN_WITH_GOOGLE', () => {
expect(validateAuthMethod(AuthType.LOGIN_WITH_GOOGLE)).toBeNull();
});
it('should return an error message for USE_OPENAI if OPENAI_API_KEY is not set', () => {
delete process.env['OPENAI_API_KEY'];
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe(
'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.',
);
it('should return null for CLOUD_SHELL', () => {
expect(validateAuthMethod(AuthType.CLOUD_SHELL)).toBeNull();
});
it('should return null for QWEN_OAUTH', () => {
expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull();
describe('USE_GEMINI', () => {
it('should return null if GEMINI_API_KEY is set', () => {
vi.stubEnv('GEMINI_API_KEY', 'test-key');
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
});
it('should return an error message if GEMINI_API_KEY is not set', () => {
vi.stubEnv('GEMINI_API_KEY', undefined);
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBe(
'GEMINI_API_KEY environment variable not found. Add that to your environment and try again (no reload needed if using .env)!',
);
});
});
describe('USE_VERTEX_AI', () => {
it('should return null if GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are set', () => {
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'test-project');
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'test-location');
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should return null if GOOGLE_API_KEY is set', () => {
vi.stubEnv('GOOGLE_API_KEY', 'test-api-key');
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should return an error message if no required environment variables are set', () => {
vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined);
vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined);
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBe(
'When using Vertex AI, you must specify either:\n' +
'• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' +
'• GOOGLE_API_KEY environment variable (if using express mode).\n' +
'Update your environment and try again (no reload needed if using .env)!',
);
});
});
it('should return an error message for an invalid auth method', () => {

View File

@@ -8,13 +8,39 @@ import { AuthType } from '@qwen-code/qwen-code-core';
import { loadEnvironment, loadSettings } from './settings.js';
export function validateAuthMethod(authMethod: string): string | null {
const settings = loadSettings();
loadEnvironment(settings.merged);
loadEnvironment(loadSettings().merged);
if (
authMethod === AuthType.LOGIN_WITH_GOOGLE ||
authMethod === AuthType.CLOUD_SHELL
) {
return null;
}
if (authMethod === AuthType.USE_GEMINI) {
if (!process.env['GEMINI_API_KEY']) {
return 'GEMINI_API_KEY environment variable not found. Add that to your environment and try again (no reload needed if using .env)!';
}
return null;
}
if (authMethod === AuthType.USE_VERTEX_AI) {
const hasVertexProjectLocationConfig =
!!process.env['GOOGLE_CLOUD_PROJECT'] &&
!!process.env['GOOGLE_CLOUD_LOCATION'];
const hasGoogleApiKey = !!process.env['GOOGLE_API_KEY'];
if (!hasVertexProjectLocationConfig && !hasGoogleApiKey) {
return (
'When using Vertex AI, you must specify either:\n' +
'• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' +
'• GOOGLE_API_KEY environment variable (if using express mode).\n' +
'Update your environment and try again (no reload needed if using .env)!'
);
}
return null;
}
if (authMethod === AuthType.USE_OPENAI) {
const hasApiKey =
process.env['OPENAI_API_KEY'] || settings.merged.security?.auth?.apiKey;
if (!hasApiKey) {
if (!process.env['OPENAI_API_KEY']) {
return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.';
}
return null;
@@ -28,3 +54,15 @@ export function validateAuthMethod(authMethod: string): string | null {
return 'Invalid auth method selected.';
}
export const setOpenAIApiKey = (apiKey: string): void => {
process.env['OPENAI_API_KEY'] = apiKey;
};
export const setOpenAIBaseUrl = (baseUrl: string): void => {
process.env['OPENAI_BASE_URL'] = baseUrl;
};
export const setOpenAIModel = (model: string): void => {
process.env['OPENAI_MODEL'] = model;
};

View File

@@ -2051,7 +2051,7 @@ describe('loadCliConfig extensions', () => {
});
describe('loadCliConfig model selection', () => {
it.skip('selects a model from settings.json if provided', async () => {
it('selects a model from settings.json if provided', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
@@ -2072,7 +2072,7 @@ describe('loadCliConfig model selection', () => {
expect(config.getModel()).toBe('qwen3-coder-plus');
});
it.skip('uses the default gemini model if nothing is set', async () => {
it('uses the default gemini model if nothing is set', async () => {
process.argv = ['node', 'script.js']; // No model set.
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
@@ -2399,73 +2399,6 @@ describe('loadCliConfig useRipgrep', () => {
});
});
describe('loadCliConfig useBuiltinRipgrep', () => {
const originalArgv = process.argv;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
});
afterEach(() => {
process.argv = originalArgv;
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should be true by default when useBuiltinRipgrep is not set in settings', 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,
),
'test-session',
argv,
);
expect(config.getUseBuiltinRipgrep()).toBe(true);
});
it('should be false when useBuiltinRipgrep is set to false in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { tools: { useBuiltinRipgrep: false } };
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getUseBuiltinRipgrep()).toBe(false);
});
it('should be true when useBuiltinRipgrep is explicitly set to true in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { tools: { useBuiltinRipgrep: true } };
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getUseBuiltinRipgrep()).toBe(true);
});
});
describe('screenReader configuration', () => {
const originalArgv = process.argv;

View File

@@ -13,6 +13,7 @@ import { extensionsCommand } from '../commands/extensions.js';
import {
ApprovalMode,
Config,
DEFAULT_QWEN_MODEL,
DEFAULT_QWEN_EMBEDDING_MODEL,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
EditTool,
@@ -42,7 +43,6 @@ import { mcpCommand } from '../commands/mcp.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { buildWebSearchConfig } from './webSearch.js';
// Simple console logger for now - replace with actual logger if available
const logger = {
@@ -114,13 +114,9 @@ export interface CliArgs {
openaiLogging: boolean | undefined;
openaiApiKey: string | undefined;
openaiBaseUrl: string | undefined;
openaiLoggingDir: string | undefined;
proxy: string | undefined;
includeDirectories: string[] | undefined;
tavilyApiKey: string | undefined;
googleApiKey: string | undefined;
googleSearchEngineId: string | undefined;
webSearchDefault: string | undefined;
screenReader: boolean | undefined;
vlmSwitchMode: string | undefined;
useSmartEdit: boolean | undefined;
@@ -198,13 +194,14 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
})
.option('proxy', {
type: 'string',
description: 'Proxy for Qwen Code, like schema://user:password@host:port',
description:
'Proxy for gemini client, like schema://user:password@host:port',
})
.deprecateOption(
'proxy',
'Use the "proxy" setting in settings.json instead. This flag will be removed in a future version.',
)
.command('$0 [query..]', 'Launch Qwen Code CLI', (yargsInstance: Argv) =>
.command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance: Argv) =>
yargsInstance
.positional('query', {
description:
@@ -318,11 +315,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
description:
'Enable logging of OpenAI API calls for debugging and analysis',
})
.option('openai-logging-dir', {
type: 'string',
description:
'Custom directory path for OpenAI API logs. Overrides settings files.',
})
.option('openai-api-key', {
type: 'string',
description: 'OpenAI API key to use for authentication',
@@ -333,20 +325,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
})
.option('tavily-api-key', {
type: 'string',
description: 'Tavily API key for web search',
})
.option('google-api-key', {
type: 'string',
description: 'Google Custom Search API key',
})
.option('google-search-engine-id', {
type: 'string',
description: 'Google Custom Search Engine ID',
})
.option('web-search-default', {
type: 'string',
description:
'Default web search provider (dashscope, tavily, google)',
description: 'Tavily API key for web search functionality',
})
.option('screen-reader', {
type: 'boolean',
@@ -690,11 +669,13 @@ export async function loadCliConfig(
);
}
const resolvedModel =
const defaultModel = DEFAULT_QWEN_MODEL;
const resolvedModel: string =
argv.model ||
process.env['OPENAI_MODEL'] ||
process.env['QWEN_MODEL'] ||
settings.model?.name;
settings.model?.name ||
defaultModel;
const sandboxConfig = await loadSandboxConfig(settings, argv);
const screenReader =
@@ -758,27 +739,18 @@ export async function loadCliConfig(
generationConfig: {
...(settings.model?.generationConfig || {}),
model: resolvedModel,
apiKey:
argv.openaiApiKey ||
process.env['OPENAI_API_KEY'] ||
settings.security?.auth?.apiKey,
baseUrl:
argv.openaiBaseUrl ||
process.env['OPENAI_BASE_URL'] ||
settings.security?.auth?.baseUrl,
apiKey: argv.openaiApiKey || process.env['OPENAI_API_KEY'],
baseUrl: argv.openaiBaseUrl || process.env['OPENAI_BASE_URL'],
enableOpenAILogging:
(typeof argv.openaiLogging === 'undefined'
? settings.model?.enableOpenAILogging
: argv.openaiLogging) ?? false,
openAILoggingDir:
argv.openaiLoggingDir || settings.model?.openAILoggingDir,
},
cliVersion: await getCliVersion(),
webSearch: buildWebSearchConfig(
argv,
settings,
settings.security?.auth?.selectedType,
),
tavilyApiKey:
argv.tavilyApiKey ||
settings.advanced?.tavilyApiKey ||
process.env['TAVILY_API_KEY'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
chatCompression: settings.model?.chatCompression,
@@ -786,12 +758,10 @@ export async function loadCliConfig(
interactive,
trustedFolder,
useRipgrep: settings.tools?.useRipgrep,
useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep,
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
skipLoopDetection: settings.model?.skipLoopDetection ?? false,
skipStartupContext: settings.model?.skipStartupContext ?? false,
vlmSwitchMode,
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,

View File

@@ -66,8 +66,6 @@ import {
loadEnvironment,
migrateDeprecatedSettings,
SettingScope,
SETTINGS_VERSION,
SETTINGS_VERSION_KEY,
} from './settings.js';
import { FatalConfigError, QWEN_DIR } from '@qwen-code/qwen-code-core';
@@ -96,7 +94,6 @@ vi.mock('fs', async (importOriginal) => {
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
renameSync: vi.fn(),
mkdirSync: vi.fn(),
realpathSync: (p: string) => p,
};
@@ -174,15 +171,11 @@ describe('Settings Loading and Merging', () => {
getSystemSettingsPath(),
'utf-8',
);
expect(settings.system.settings).toEqual({
...systemSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.system.settings).toEqual(systemSettingsContent);
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual({
...systemSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
});
@@ -214,14 +207,10 @@ describe('Settings Loading and Merging', () => {
expectedUserSettingsPath,
'utf-8',
);
expect(settings.user.settings).toEqual({
...userSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.user.settings).toEqual(userSettingsContent);
expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual({
...userSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
});
@@ -252,13 +241,9 @@ describe('Settings Loading and Merging', () => {
'utf-8',
);
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual({
...workspaceSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
expect(settings.merged).toEqual({
...workspaceSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
});
@@ -319,20 +304,10 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.system.settings).toEqual({
...systemSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.user.settings).toEqual({
...userSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.workspace.settings).toEqual({
...workspaceSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.system.settings).toEqual(systemSettingsContent);
expect(settings.user.settings).toEqual(userSettingsContent);
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
expect(settings.merged).toEqual({
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
ui: {
theme: 'system-theme',
},
@@ -386,7 +361,6 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged).toEqual({
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
ui: {
theme: 'legacy-dark',
},
@@ -439,132 +413,6 @@ describe('Settings Loading and Merging', () => {
expect((settings.merged as TestSettings)['allowedTools']).toBeUndefined();
});
it('should add version field to migrated settings file', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const legacySettingsContent = {
theme: 'dark',
model: 'qwen-coder',
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(legacySettingsContent);
return '{}';
},
);
loadSettings(MOCK_WORKSPACE_DIR);
// Verify that fs.writeFileSync was called with migrated settings including version
expect(fs.writeFileSync).toHaveBeenCalled();
const writeCall = (fs.writeFileSync as Mock).mock.calls[0];
const writtenContent = JSON.parse(writeCall[1] as string);
expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION);
});
it('should not re-migrate settings that have version field', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const migratedSettingsContent = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
ui: {
theme: 'dark',
},
model: {
name: 'qwen-coder',
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(migratedSettingsContent);
return '{}';
},
);
loadSettings(MOCK_WORKSPACE_DIR);
// Verify that fs.renameSync and fs.writeFileSync were NOT called
// (because no migration was needed)
expect(fs.renameSync).not.toHaveBeenCalled();
expect(fs.writeFileSync).not.toHaveBeenCalled();
});
it('should add version field to V2 settings without version and write to disk', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
// V2 format but no version field
const v2SettingsWithoutVersion = {
ui: {
theme: 'dark',
},
model: {
name: 'qwen-coder',
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(v2SettingsWithoutVersion);
return '{}';
},
);
loadSettings(MOCK_WORKSPACE_DIR);
// Verify that fs.writeFileSync was called (to add version)
// but NOT fs.renameSync (no backup needed, just adding version)
expect(fs.renameSync).not.toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
const writeCall = (fs.writeFileSync as Mock).mock.calls[0];
const writtenPath = writeCall[0];
const writtenContent = JSON.parse(writeCall[1] as string);
expect(writtenPath).toBe(USER_SETTINGS_PATH);
expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION);
expect(writtenContent.ui?.theme).toBe('dark');
expect(writtenContent.model?.name).toBe('qwen-coder');
});
it('should correctly handle partially migrated settings without version field', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
// Edge case: model already in V2 format (object), but autoAccept in V1 format
const partiallyMigratedContent = {
model: {
name: 'qwen-coder',
},
autoAccept: false, // V1 key
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(partiallyMigratedContent);
return '{}';
},
);
loadSettings(MOCK_WORKSPACE_DIR);
// Verify that the migrated settings preserve the model object correctly
expect(fs.writeFileSync).toHaveBeenCalled();
const writeCall = (fs.writeFileSync as Mock).mock.calls[0];
const writtenContent = JSON.parse(writeCall[1] as string);
// Model should remain as an object, not double-nested
expect(writtenContent.model).toEqual({ name: 'qwen-coder' });
// autoAccept should be migrated to tools.autoAccept
expect(writtenContent.tools?.autoAccept).toBe(false);
// Version field should be added
expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION);
});
it('should correctly merge and migrate legacy array properties from multiple scopes', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const legacyUserSettings = {
@@ -667,24 +515,11 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.systemDefaults.settings).toEqual({
...systemDefaultsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.system.settings).toEqual({
...systemSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.user.settings).toEqual({
...userSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.workspace.settings).toEqual({
...workspaceSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.systemDefaults.settings).toEqual(systemDefaultsContent);
expect(settings.system.settings).toEqual(systemSettingsContent);
expect(settings.user.settings).toEqual(userSettingsContent);
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
expect(settings.merged).toEqual({
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
context: {
fileName: 'WORKSPACE_CONTEXT.md',
includeDirectories: [
@@ -1031,14 +866,8 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings).toEqual({
...userSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.workspace.settings).toEqual({
...workspaceSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.user.settings).toEqual(userSettingsContent);
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
expect(settings.merged.mcpServers).toEqual({
'user-server': {
command: 'user-command',
@@ -1867,13 +1696,9 @@ describe('Settings Loading and Merging', () => {
'utf-8',
);
expect(settings.system.path).toBe(MOCK_ENV_SYSTEM_SETTINGS_PATH);
expect(settings.system.settings).toEqual({
...systemSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.system.settings).toEqual(systemSettingsContent);
expect(settings.merged).toEqual({
...systemSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
});
});
@@ -2423,44 +2248,6 @@ describe('Settings Loading and Merging', () => {
customWittyPhrases: ['test phrase'],
});
});
it('should remove version field when migrating to V1', () => {
const v2Settings = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
ui: {
theme: 'dark',
},
model: {
name: 'qwen-coder',
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
// Version field should not be present in V1 settings
expect(v1Settings[SETTINGS_VERSION_KEY]).toBeUndefined();
// Other fields should be properly migrated
expect(v1Settings).toEqual({
theme: 'dark',
model: 'qwen-coder',
});
});
it('should handle version field in unrecognized properties', () => {
const v2Settings = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
general: {
vimMode: true,
},
someUnrecognizedKey: 'value',
};
const v1Settings = migrateSettingsToV1(v2Settings);
// Version field should be filtered out
expect(v1Settings[SETTINGS_VERSION_KEY]).toBeUndefined();
// Unrecognized keys should be preserved
expect(v1Settings['someUnrecognizedKey']).toBe('value');
expect(v1Settings['vimMode']).toBe(true);
});
});
describe('loadEnvironment', () => {
@@ -2581,73 +2368,6 @@ describe('Settings Loading and Merging', () => {
};
expect(needsMigration(settings)).toBe(false);
});
describe('with version field', () => {
it('should return false when version field indicates current or newer version', () => {
const settingsWithVersion = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
theme: 'dark', // Even though this is a V1 key, version field takes precedence
};
expect(needsMigration(settingsWithVersion)).toBe(false);
});
it('should return false when version field indicates a newer version', () => {
const settingsWithNewerVersion = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION + 1,
theme: 'dark',
};
expect(needsMigration(settingsWithNewerVersion)).toBe(false);
});
it('should return true when version field indicates an older version', () => {
const settingsWithOldVersion = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION - 1,
theme: 'dark',
};
expect(needsMigration(settingsWithOldVersion)).toBe(true);
});
it('should use fallback logic when version field is not a number', () => {
const settingsWithInvalidVersion = {
[SETTINGS_VERSION_KEY]: 'not-a-number',
theme: 'dark',
};
expect(needsMigration(settingsWithInvalidVersion)).toBe(true);
});
it('should use fallback logic when version field is missing', () => {
const settingsWithoutVersion = {
theme: 'dark',
};
expect(needsMigration(settingsWithoutVersion)).toBe(true);
});
});
describe('edge case: partially migrated settings', () => {
it('should return true for partially migrated settings without version field', () => {
// This simulates the dangerous edge case: model already in V2 format,
// but other fields in V1 format
const partiallyMigrated = {
model: {
name: 'qwen-coder',
},
autoAccept: false, // V1 key
};
expect(needsMigration(partiallyMigrated)).toBe(true);
});
it('should return false for partially migrated settings WITH version field', () => {
// With version field, we trust that it's been properly migrated
const partiallyMigratedWithVersion = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
model: {
name: 'qwen-coder',
},
autoAccept: false, // This would look like V1 but version says it's V2
};
expect(needsMigration(partiallyMigratedWithVersion)).toBe(false);
});
});
});
describe('migrateDeprecatedSettings', () => {

View File

@@ -56,10 +56,6 @@ export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
const MIGRATE_V2_OVERWRITE = true;
// Settings version to track migration state
export const SETTINGS_VERSION = 2;
export const SETTINGS_VERSION_KEY = '$version';
const MIGRATION_MAP: Record<string, string> = {
accessibility: 'ui.accessibility',
allowedTools: 'tools.allowed',
@@ -131,7 +127,6 @@ const MIGRATION_MAP: Record<string, string> = {
sessionTokenLimit: 'model.sessionTokenLimit',
contentGenerator: 'model.generationConfig',
skipLoopDetection: 'model.skipLoopDetection',
skipStartupContext: 'model.skipStartupContext',
enableOpenAILogging: 'model.enableOpenAILogging',
tavilyApiKey: 'advanced.tavilyApiKey',
vlmSwitchMode: 'experimental.vlmSwitchMode',
@@ -221,16 +216,8 @@ function setNestedProperty(
}
export function needsMigration(settings: Record<string, unknown>): boolean {
// Check version field first - if present and matches current version, no migration needed
if (SETTINGS_VERSION_KEY in settings) {
const version = settings[SETTINGS_VERSION_KEY];
if (typeof version === 'number' && version >= SETTINGS_VERSION) {
return false;
}
}
// Fallback to legacy detection: A file needs migration if it contains any
// top-level key that is moved to a nested location in V2.
// A file needs migration if it contains any top-level key that is moved to a
// nested location in V2.
const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => {
if (v1Key === v2Path || !(v1Key in settings)) {
return false;
@@ -263,21 +250,6 @@ function migrateSettingsToV2(
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
if (flatKeys.has(oldKey)) {
// Safety check: If this key is a V2 container (like 'model') and it's
// already an object, it's likely already in V2 format. Skip migration
// to prevent double-nesting (e.g., model.name.name).
if (
KNOWN_V2_CONTAINERS.has(oldKey) &&
typeof flatSettings[oldKey] === 'object' &&
flatSettings[oldKey] !== null &&
!Array.isArray(flatSettings[oldKey])
) {
// This is already a V2 container, carry it over as-is
v2Settings[oldKey] = flatSettings[oldKey];
flatKeys.delete(oldKey);
continue;
}
setNestedProperty(v2Settings, newPath, flatSettings[oldKey]);
flatKeys.delete(oldKey);
}
@@ -315,9 +287,6 @@ function migrateSettingsToV2(
}
}
// Set version field to indicate this is a V2 settings file
v2Settings[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
return v2Settings;
}
@@ -367,11 +336,6 @@ export function migrateSettingsToV1(
// Carry over any unrecognized keys
for (const remainingKey of v2Keys) {
// Skip the version field - it's only for V2 format
if (remainingKey === SETTINGS_VERSION_KEY) {
continue;
}
const value = v2Settings[remainingKey];
if (value === undefined) {
continue;
@@ -657,22 +621,6 @@ export function loadSettings(
}
settingsObject = migratedSettings;
}
} else if (!(SETTINGS_VERSION_KEY in settingsObject)) {
// No migration needed, but version field is missing - add it for future optimizations
settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
if (MIGRATE_V2_OVERWRITE) {
try {
fs.writeFileSync(
filePath,
JSON.stringify(settingsObject, null, 2),
'utf-8',
);
} catch (e) {
console.error(
`Error adding version to settings file: ${getErrorMessage(e)}`,
);
}
}
}
return { settings: settingsObject as Settings, rawJson: content };
}
@@ -839,6 +787,5 @@ export function saveSettings(settingsFile: SettingsFile): void {
);
} catch (error) {
console.error('Error saving user settings file:', error);
throw error;
}
}

View File

@@ -12,7 +12,6 @@ import type {
ChatCompressionSettings,
} from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
} from '@qwen-code/qwen-code-core';
@@ -550,16 +549,6 @@ const SETTINGS_SCHEMA = {
description: 'Disable all loop detection checks (streaming and LLM).',
showInDialog: true,
},
skipStartupContext: {
type: 'boolean',
label: 'Skip Startup Context',
category: 'Model',
requiresRestart: true,
default: false,
description:
'Avoid sending the workspace startup context at the beginning of each session.',
showInDialog: true,
},
enableOpenAILogging: {
type: 'boolean',
label: 'Enable OpenAI Logging',
@@ -569,16 +558,6 @@ const SETTINGS_SCHEMA = {
description: 'Enable OpenAI logging.',
showInDialog: true,
},
openAILoggingDir: {
type: 'string',
label: 'OpenAI Logging Directory',
category: 'Model',
requiresRestart: false,
default: undefined as string | undefined,
description:
'Custom directory path for OpenAI API logs. If not specified, defaults to logs/openai in the current working directory.',
showInDialog: true,
},
generationConfig: {
type: 'object',
label: 'Generation Configuration',
@@ -831,20 +810,14 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.UNION,
},
approvalMode: {
type: 'enum',
label: 'Approval Mode',
type: 'string',
label: 'Default Approval Mode',
category: 'Tools',
requiresRestart: false,
default: ApprovalMode.DEFAULT,
default: 'default',
description:
'Approval mode for tool usage. Controls how tools are approved before execution.',
'Default approval mode for tool usage. Valid values: plan, default, auto-edit, yolo.',
showInDialog: true,
options: [
{ value: ApprovalMode.PLAN, label: 'Plan' },
{ value: ApprovalMode.DEFAULT, label: 'Default' },
{ value: ApprovalMode.AUTO_EDIT, label: 'Auto Edit' },
{ value: ApprovalMode.YOLO, label: 'YOLO' },
],
},
discoveryCommand: {
type: 'string',
@@ -874,16 +847,6 @@ const SETTINGS_SCHEMA = {
'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.',
showInDialog: true,
},
useBuiltinRipgrep: {
type: 'boolean',
label: 'Use Builtin Ripgrep',
category: 'Tools',
requiresRestart: false,
default: true,
description:
'Use the bundled ripgrep binary. When set to false, the system-level "rg" command will be used instead. This setting is only effective when useRipgrep is true.',
showInDialog: true,
},
enableToolOutputTruncation: {
type: 'boolean',
label: 'Enable Tool Output Truncation',
@@ -1028,24 +991,6 @@ const SETTINGS_SCHEMA = {
description: 'Whether to use an external authentication flow.',
showInDialog: false,
},
apiKey: {
type: 'string',
label: 'API Key',
category: 'Security',
requiresRestart: true,
default: undefined as string | undefined,
description: 'API key for OpenAI compatible authentication.',
showInDialog: false,
},
baseUrl: {
type: 'string',
label: 'Base URL',
category: 'Security',
requiresRestart: true,
default: undefined as string | undefined,
description: 'Base URL for OpenAI compatible API.',
showInDialog: false,
},
},
},
},
@@ -1099,36 +1044,17 @@ const SETTINGS_SCHEMA = {
},
tavilyApiKey: {
type: 'string',
label: 'Tavily API Key (Deprecated)',
label: 'Tavily API Key',
category: 'Advanced',
requiresRestart: false,
default: undefined as string | undefined,
description:
'⚠️ DEPRECATED: Please use webSearch.provider configuration instead. Legacy API key for the Tavily API.',
'The API key for the Tavily API. Required to enable the web_search tool functionality.',
showInDialog: false,
},
},
},
webSearch: {
type: 'object',
label: 'Web Search',
category: 'Advanced',
requiresRestart: true,
default: undefined as
| {
provider: Array<{
type: 'tavily' | 'google' | 'dashscope';
apiKey?: string;
searchEngineId?: string;
}>;
default: string;
}
| undefined,
description: 'Configuration for web search providers.',
showInDialog: false,
},
experimental: {
type: 'object',
label: 'Experimental',

View File

@@ -1,121 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType } from '@qwen-code/qwen-code-core';
import type { WebSearchProviderConfig } from '@qwen-code/qwen-code-core';
import type { Settings } from './settings.js';
/**
* CLI arguments related to web search configuration
*/
export interface WebSearchCliArgs {
tavilyApiKey?: string;
googleApiKey?: string;
googleSearchEngineId?: string;
webSearchDefault?: string;
}
/**
* Web search configuration structure
*/
export interface WebSearchConfig {
provider: WebSearchProviderConfig[];
default: string;
}
/**
* Build webSearch configuration from multiple sources with priority:
* 1. settings.json (new format) - highest priority
* 2. Command line args + environment variables
* 3. Legacy tavilyApiKey (backward compatibility)
*
* @param argv - Command line arguments
* @param settings - User settings from settings.json
* @param authType - Authentication type (e.g., 'qwen-oauth')
* @returns WebSearch configuration or undefined if no providers available
*/
export function buildWebSearchConfig(
argv: WebSearchCliArgs,
settings: Settings,
authType?: string,
): WebSearchConfig | undefined {
const isQwenOAuth = authType === AuthType.QWEN_OAUTH;
// Step 1: Collect providers from settings or command line/env
let providers: WebSearchProviderConfig[] = [];
let userDefault: string | undefined;
if (settings.webSearch) {
// Use providers from settings.json
providers = [...settings.webSearch.provider];
userDefault = settings.webSearch.default;
} else {
// Build providers from command line args and environment variables
const tavilyKey =
argv.tavilyApiKey ||
settings.advanced?.tavilyApiKey ||
process.env['TAVILY_API_KEY'];
if (tavilyKey) {
providers.push({
type: 'tavily',
apiKey: tavilyKey,
} as WebSearchProviderConfig);
}
const googleKey = argv.googleApiKey || process.env['GOOGLE_API_KEY'];
const googleEngineId =
argv.googleSearchEngineId || process.env['GOOGLE_SEARCH_ENGINE_ID'];
if (googleKey && googleEngineId) {
providers.push({
type: 'google',
apiKey: googleKey,
searchEngineId: googleEngineId,
} as WebSearchProviderConfig);
}
}
// Step 2: Ensure dashscope is available for qwen-oauth users
if (isQwenOAuth) {
const hasDashscope = providers.some((p) => p.type === 'dashscope');
if (!hasDashscope) {
providers.push({ type: 'dashscope' } as WebSearchProviderConfig);
}
}
// Step 3: If no providers available, return undefined
if (providers.length === 0) {
return undefined;
}
// Step 4: Determine default provider
// Priority: user explicit config > CLI arg > first available provider (tavily > google > dashscope)
const providerPriority: Array<'tavily' | 'google' | 'dashscope'> = [
'tavily',
'google',
'dashscope',
];
// Determine default provider based on availability
let defaultProvider = userDefault || argv.webSearchDefault;
if (!defaultProvider) {
// Find first available provider by priority order
for (const providerType of providerPriority) {
if (providers.some((p) => p.type === providerType)) {
defaultProvider = providerType;
break;
}
}
// Fallback to first available provider if none found in priority list
if (!defaultProvider) {
defaultProvider = providers[0]?.type || 'dashscope';
}
}
return {
provider: providers,
default: defaultProvider,
};
}

View File

@@ -8,8 +8,6 @@ import {
type AuthType,
type Config,
getErrorMessage,
logAuth,
AuthEvent,
} from '@qwen-code/qwen-code-core';
/**
@@ -27,21 +25,11 @@ export async function performInitialAuth(
}
try {
await config.refreshAuth(authType, true);
await config.refreshAuth(authType);
// The console.log is intentionally left out here.
// We can add a dedicated startup message later if needed.
// Log authentication success
const authEvent = new AuthEvent(authType, 'auto', 'success');
logAuth(config, authEvent);
} catch (e) {
const errorMessage = `Failed to login. Message: ${getErrorMessage(e)}`;
// Log authentication failure
const authEvent = new AuthEvent(authType, 'auto', 'error', errorMessage);
logAuth(config, authEvent);
return errorMessage;
return `Failed to login. Message: ${getErrorMessage(e)}`;
}
return null;

View File

@@ -11,7 +11,7 @@ import {
logIdeConnection,
type Config,
} from '@qwen-code/qwen-code-core';
import { type LoadedSettings, SettingScope } from '../config/settings.js';
import { type LoadedSettings } from '../config/settings.js';
import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
@@ -33,18 +33,10 @@ export async function initializeApp(
config: Config,
settings: LoadedSettings,
): Promise<InitializationResult> {
const authType = settings.merged.security?.auth?.selectedType;
const authError = await performInitialAuth(config, authType);
// Fallback to user select when initial authentication fails
if (authError) {
settings.setValue(
SettingScope.User,
'security.auth.selectedType',
undefined,
);
}
const authError = await performInitialAuth(
config,
settings.merged.security?.auth?.selectedType,
);
const themeError = validateTheme(settings);
const shouldOpenAuthDialog =

View File

@@ -327,13 +327,9 @@ describe('gemini.tsx main function kitty protocol', () => {
openaiLogging: undefined,
openaiApiKey: undefined,
openaiBaseUrl: undefined,
openaiLoggingDir: undefined,
proxy: undefined,
includeDirectories: undefined,
tavilyApiKey: undefined,
googleApiKey: undefined,
googleSearchEngineId: undefined,
webSearchDefault: undefined,
screenReader: undefined,
vlmSwitchMode: undefined,
useSmartEdit: undefined,

View File

@@ -17,7 +17,11 @@ import dns from 'node:dns';
import { randomUUID } from 'node:crypto';
import { start_sandbox } from './utils/sandbox.js';
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
import {
loadSettings,
migrateDeprecatedSettings,
SettingScope,
} from './config/settings.js';
import { themeManager } from './ui/themes/theme-manager.js';
import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
@@ -229,6 +233,17 @@ export async function main() {
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
);
// Set a default auth type if one isn't set.
if (!settings.merged.security?.auth?.selectedType) {
if (process.env['CLOUD_SHELL'] === 'true') {
settings.setValue(
SettingScope.User,
'selectedAuthType',
AuthType.CLOUD_SHELL,
);
}
}
// Load custom themes from settings
themeManager.loadCustomThemes(settings.merged.ui?.customThemes);
@@ -387,11 +402,7 @@ 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,
})),
...(await getUserStartupWarnings()),
];
// Render UI, passing necessary config values. Check that there is no command line question.

View File

@@ -23,7 +23,6 @@ import type { Part } from '@google/genai';
import { runNonInteractive } from './nonInteractiveCli.js';
import { vi } from 'vitest';
import type { LoadedSettings } from './config/settings.js';
import { CommandKind } from './ui/commands/types.js';
// Mock core modules
vi.mock('./ui/hooks/atCommandProcessor.js');
@@ -728,7 +727,6 @@ describe('runNonInteractive', () => {
const mockCommand = {
name: 'testcommand',
description: 'a test command',
kind: CommandKind.FILE,
action: vi.fn().mockResolvedValue({
type: 'submit_prompt',
content: [{ text: 'Prompt from command' }],
@@ -768,7 +766,6 @@ describe('runNonInteractive', () => {
const mockCommand = {
name: 'confirm',
description: 'a command that needs confirmation',
kind: CommandKind.FILE,
action: vi.fn().mockResolvedValue({
type: 'confirm_shell_commands',
commands: ['rm -rf /'],
@@ -824,7 +821,6 @@ describe('runNonInteractive', () => {
const mockCommand = {
name: 'noaction',
description: 'unhandled type',
kind: CommandKind.FILE,
action: vi.fn().mockResolvedValue({
type: 'unhandled',
}),
@@ -851,7 +847,6 @@ describe('runNonInteractive', () => {
const mockCommand = {
name: 'testargs',
description: 'a test command',
kind: CommandKind.FILE,
action: mockAction,
};
mockGetCommands.mockReturnValue([mockCommand]);

View File

@@ -13,56 +13,15 @@ import {
type Config,
} from '@qwen-code/qwen-code-core';
import { CommandService } from './services/CommandService.js';
import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
import { FileCommandLoader } from './services/FileCommandLoader.js';
import {
CommandKind,
type CommandContext,
type SlashCommand,
} from './ui/commands/types.js';
import type { CommandContext } from './ui/commands/types.js';
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
import type { LoadedSettings } from './config/settings.js';
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
/**
* Filters commands based on the allowed built-in command names.
*
* - Always includes FILE commands
* - Only includes BUILT_IN commands if their name is in the allowed set
* - Excludes other command types (e.g., MCP_PROMPT) in non-interactive mode
*
* @param commands All loaded commands
* @param allowedBuiltinCommandNames Set of allowed built-in command names (empty = none allowed)
* @returns Filtered commands
*/
function filterCommandsForNonInteractive(
commands: readonly SlashCommand[],
allowedBuiltinCommandNames: Set<string>,
): SlashCommand[] {
return commands.filter((cmd) => {
if (cmd.kind === CommandKind.FILE) {
return true;
}
// Built-in commands: only include if in the allowed list
if (cmd.kind === CommandKind.BUILT_IN) {
return allowedBuiltinCommandNames.has(cmd.name);
}
// Exclude other types (e.g., MCP_PROMPT) in non-interactive mode
return false;
});
}
/**
* Processes a slash command in a non-interactive environment.
*
* @param rawQuery The raw query string (should start with '/')
* @param abortController Controller to cancel the operation
* @param config The configuration object
* @param settings The loaded settings
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
* allowed. If not provided or empty, only file commands are available.
* @returns A Promise that resolves to `PartListUnion` if a valid command is
* found and results in a prompt, or `undefined` otherwise.
* @throws {FatalInputError} if the command result is not supported in
@@ -73,35 +32,21 @@ export const handleSlashCommand = async (
abortController: AbortController,
config: Config,
settings: LoadedSettings,
allowedBuiltinCommandNames?: string[],
): Promise<PartListUnion | undefined> => {
const trimmed = rawQuery.trim();
if (!trimmed.startsWith('/')) {
return;
}
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
// Only load BuiltinCommandLoader if there are allowed built-in commands
const loaders =
allowedBuiltinSet.size > 0
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
: [new FileCommandLoader(config)];
// Only custom commands are supported for now.
const loaders = [new FileCommandLoader(config)];
const commandService = await CommandService.create(
loaders,
abortController.signal,
);
const commands = commandService.getCommands();
const filteredCommands = filterCommandsForNonInteractive(
commands,
allowedBuiltinSet,
);
const { commandToExecute, args } = parseSlashCommand(
rawQuery,
filteredCommands,
);
const { commandToExecute, args } = parseSlashCommand(rawQuery, commands);
if (commandToExecute) {
if (commandToExecute.action) {
@@ -162,44 +107,3 @@ export const handleSlashCommand = async (
return;
};
/**
* Retrieves all available slash commands for the current configuration.
*
* @param config The configuration object
* @param settings The loaded settings
* @param abortSignal Signal to cancel the loading process
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
* allowed. If not provided or empty, only file commands are available.
* @returns A Promise that resolves to an array of SlashCommand objects
*/
export const getAvailableCommands = async (
config: Config,
settings: LoadedSettings,
abortSignal: AbortSignal,
allowedBuiltinCommandNames?: string[],
): Promise<SlashCommand[]> => {
try {
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
// Only load BuiltinCommandLoader if there are allowed built-in commands
const loaders =
allowedBuiltinSet.size > 0
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
: [new FileCommandLoader(config)];
const commandService = await CommandService.create(loaders, abortSignal);
const commands = commandService.getCommands();
const filteredCommands = filterCommandsForNonInteractive(
commands,
allowedBuiltinSet,
);
// Filter out hidden commands
return filteredCommands.filter((cmd) => !cmd.hidden);
} catch (error) {
// Handle errors gracefully - log and return empty array
console.error('Error loading available commands:', error);
return [];
}
};

View File

@@ -1227,28 +1227,4 @@ describe('FileCommandLoader', () => {
expect(commands).toHaveLength(0);
});
});
describe('AbortError handling', () => {
it('should silently ignore AbortError when operation is cancelled', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test1.toml': 'prompt = "Prompt 1"',
'test2.toml': 'prompt = "Prompt 2"',
},
});
const loader = new FileCommandLoader(null);
const controller = new AbortController();
const signal = controller.signal;
// Start loading and immediately abort
const loadPromise = loader.loadCommands(signal);
controller.abort();
// Should not throw or print errors
const commands = await loadPromise;
expect(commands).toHaveLength(0);
});
});
});

View File

@@ -120,11 +120,7 @@ export class FileCommandLoader implements ICommandLoader {
// Add all commands without deduplication
allCommands.push(...commands);
} catch (error) {
// Ignore ENOENT (directory doesn't exist) and AbortError (operation was cancelled)
const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT';
const isAbortError =
error instanceof Error && error.name === 'AbortError';
if (!isEnoent && !isAbortError) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error(
`[FileCommandLoader] Error loading commands from ${dirInfo.path}:`,
error,

View File

@@ -25,6 +25,7 @@ import {
type HistoryItem,
ToolCallStatus,
type HistoryItemWithoutId,
AuthState,
} from './types.js';
import { MessageType, StreamingState } from './types.js';
import {
@@ -47,11 +48,11 @@ import { useHistory } from './hooks/useHistoryManager.js';
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useAuthCommand } from './auth/useAuth.js';
import { useQwenAuth } from './hooks/useQwenAuth.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useVimMode } from './contexts/VimModeContext.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
@@ -91,12 +92,10 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js';
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js';
import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
@@ -336,25 +335,26 @@ export const AppContainer = (props: AppContainerProps) => {
initializationResult.themeError,
);
const {
isApprovalModeDialogOpen,
openApprovalModeDialog,
handleApprovalModeSelect,
} = useApprovalModeCommand(settings, config);
const {
setAuthState,
authError,
onAuthError,
isAuthDialogOpen,
isAuthenticating,
pendingAuthType,
qwenAuthState,
handleAuthSelect,
openAuthDialog,
cancelAuthentication,
} = useAuthCommand(settings, config);
// Qwen OAuth authentication state
const {
isQwenAuth,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
cancelQwenAuth,
} = useQwenAuth(settings, isAuthenticating);
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
config,
historyManager,
@@ -363,7 +363,19 @@ export const AppContainer = (props: AppContainerProps) => {
setModelSwitchedFromQuotaError,
});
useInitializationAuthError(initializationResult.authError, onAuthError);
// Handle Qwen OAuth timeout
const handleQwenAuthTimeout = useCallback(() => {
onAuthError('Qwen OAuth authentication timed out. Please try again.');
cancelQwenAuth();
setAuthState(AuthState.Updating);
}, [onAuthError, cancelQwenAuth, setAuthState]);
// Handle Qwen OAuth cancel
const handleQwenAuthCancel = useCallback(() => {
onAuthError('Qwen OAuth authentication cancelled.');
cancelQwenAuth();
setAuthState(AuthState.Updating);
}, [onAuthError, cancelQwenAuth, setAuthState]);
// Sync user tier from config when authentication changes
// TODO: Implement getUserTier() method on Config if needed
@@ -375,8 +387,6 @@ export const AppContainer = (props: AppContainerProps) => {
// Check for enforced auth type mismatch
useEffect(() => {
// Check for initialization error first
if (
settings.merged.security?.auth?.enforcedType &&
settings.merged.security?.auth.selectedType &&
@@ -460,7 +470,6 @@ export const AppContainer = (props: AppContainerProps) => {
openSettingsDialog,
openModelDialog,
openPermissionsDialog,
openApprovalModeDialog,
quit: (messages: HistoryItem[]) => {
setQuittingMessages(messages);
setTimeout(async () => {
@@ -486,7 +495,6 @@ export const AppContainer = (props: AppContainerProps) => {
setCorgiMode,
dispatchExtensionStateUpdate,
openPermissionsDialog,
openApprovalModeDialog,
addConfirmUpdateExtensionRequest,
showQuitConfirmation,
openSubagentCreateDialog,
@@ -543,16 +551,11 @@ export const AppContainer = (props: AppContainerProps) => {
[visionSwitchResolver],
);
// onDebugMessage should log to console, not update footer debugMessage
const onDebugMessage = useCallback((message: string) => {
console.debug(message);
}, []);
const performMemoryRefresh = useCallback(async () => {
historyManager.addItem(
{
type: MessageType.INFO,
text: 'Refreshing hierarchical memory (QWEN.md or other context files)...',
text: 'Refreshing hierarchical memory (GEMINI.md or other context files)...',
},
Date.now(),
);
@@ -625,7 +628,7 @@ export const AppContainer = (props: AppContainerProps) => {
historyManager.addItem,
config,
settings,
onDebugMessage,
setDebugMessage,
handleSlashCommand,
shellModeActive,
() => settings.merged.general?.preferredEditor as EditorType,
@@ -913,9 +916,17 @@ export const AppContainer = (props: AppContainerProps) => {
(result: IdeIntegrationNudgeResult) => {
if (result.userSelection === 'yes') {
handleSlashCommand('/ide install');
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
} else if (result.userSelection === 'dismiss') {
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
}
setIdePromptAnswered(true);
},
@@ -927,21 +938,13 @@ export const AppContainer = (props: AppContainerProps) => {
settings.merged.ui?.customWittyPhrases,
);
useAttentionNotifications({
isFocused,
streamingState,
elapsedTime,
});
// Dialog close functionality
const { closeAnyOpenDialog } = useDialogClose({
isThemeDialogOpen,
handleThemeSelect,
isApprovalModeDialogOpen,
handleApprovalModeSelect,
isAuthDialogOpen,
handleAuthSelect,
pendingAuthType,
selectedAuthType: settings.merged.security?.auth?.selectedType,
isEditorDialogOpen,
exitEditorDialog,
isSettingsDialogOpen,
@@ -1183,13 +1186,12 @@ export const AppContainer = (props: AppContainerProps) => {
isVisionSwitchDialogOpen ||
isPermissionsDialogOpen ||
isAuthDialogOpen ||
isAuthenticating ||
(isAuthenticating && isQwenAuthenticating) ||
isEditorDialogOpen ||
showIdeRestartPrompt ||
!!proQuotaRequest ||
isSubagentCreateDialogOpen ||
isAgentsManagerDialogOpen ||
isApprovalModeDialogOpen;
isAgentsManagerDialogOpen;
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
@@ -1206,9 +1208,12 @@ export const AppContainer = (props: AppContainerProps) => {
isConfigInitialized,
authError,
isAuthDialogOpen,
pendingAuthType,
// Qwen OAuth state
qwenAuthState,
isQwenAuth,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
editorError,
isEditorDialogOpen,
corgiMode,
@@ -1217,7 +1222,6 @@ export const AppContainer = (props: AppContainerProps) => {
isSettingsDialogOpen,
isModelDialogOpen,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
@@ -1298,9 +1302,12 @@ export const AppContainer = (props: AppContainerProps) => {
isConfigInitialized,
authError,
isAuthDialogOpen,
pendingAuthType,
// Qwen OAuth state
qwenAuthState,
isQwenAuth,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
editorError,
isEditorDialogOpen,
corgiMode,
@@ -1309,7 +1316,6 @@ export const AppContainer = (props: AppContainerProps) => {
isSettingsDialogOpen,
isModelDialogOpen,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
@@ -1390,11 +1396,12 @@ export const AppContainer = (props: AppContainerProps) => {
() => ({
handleThemeSelect,
handleThemeHighlight,
handleApprovalModeSelect,
handleAuthSelect,
setAuthState,
onAuthError,
cancelAuthentication,
// Qwen OAuth handlers
handleQwenAuthTimeout,
handleQwenAuthCancel,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,
@@ -1424,11 +1431,12 @@ export const AppContainer = (props: AppContainerProps) => {
[
handleThemeSelect,
handleThemeHighlight,
handleApprovalModeSelect,
handleAuthSelect,
setAuthState,
onAuthError,
cancelAuthentication,
// Qwen OAuth handlers
handleQwenAuthTimeout,
handleQwenAuthCancel,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,

View File

@@ -9,53 +9,6 @@ import { AuthDialog } from './AuthDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../test-utils/render.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { UIActionsContext } from '../contexts/UIActionsContext.js';
import type { UIState } from '../contexts/UIStateContext.js';
import type { UIActions } from '../contexts/UIActionsContext.js';
const createMockUIState = (overrides: Partial<UIState> = {}): UIState => {
// AuthDialog only uses authError and pendingAuthType
const baseState = {
authError: null,
pendingAuthType: undefined,
} as Partial<UIState>;
return {
...baseState,
...overrides,
} as UIState;
};
const createMockUIActions = (overrides: Partial<UIActions> = {}): UIActions => {
// AuthDialog only uses handleAuthSelect
const baseActions = {
handleAuthSelect: vi.fn(),
} as Partial<UIActions>;
return {
...baseActions,
...overrides,
} as UIActions;
};
const renderAuthDialog = (
settings: LoadedSettings,
uiStateOverrides: Partial<UIState> = {},
uiActionsOverrides: Partial<UIActions> = {},
) => {
const uiState = createMockUIState(uiStateOverrides);
const uiActions = createMockUIActions(uiActionsOverrides);
return renderWithProviders(
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<AuthDialog />
</UIActionsContext.Provider>
</UIStateContext.Provider>,
{ settings },
);
};
describe('AuthDialog', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -113,9 +66,13 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderAuthDialog(settings, {
authError: 'GEMINI_API_KEY environment variable not found',
});
const { lastFrame } = renderWithProviders(
<AuthDialog
onSelect={() => {}}
settings={settings}
initialErrorMessage="GEMINI_API_KEY environment variable not found"
/>,
);
expect(lastFrame()).toContain(
'GEMINI_API_KEY environment variable not found',
@@ -159,7 +116,9 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderAuthDialog(settings);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog only shows OpenAI option now,
// it won't show GEMINI_API_KEY messages
@@ -203,7 +162,9 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderAuthDialog(settings);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
expect(lastFrame()).not.toContain(
'Existing API key detected (GEMINI_API_KEY)',
@@ -247,7 +208,9 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderAuthDialog(settings);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog only shows OpenAI option now,
// it won't show GEMINI_API_KEY messages
@@ -292,7 +255,9 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderAuthDialog(settings);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// This is a bit brittle, but it's the best way to check which item is selected.
expect(lastFrame()).toContain('● 2. OpenAI');
@@ -332,7 +297,9 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderAuthDialog(settings);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Default is Qwen OAuth (first option)
expect(lastFrame()).toContain('● 1. Qwen OAuth');
@@ -374,7 +341,9 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderAuthDialog(settings);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore,
// it will just show the default Qwen OAuth option
@@ -383,7 +352,7 @@ describe('AuthDialog', () => {
});
it('should prevent exiting when no auth method is selected and show error message', async () => {
const handleAuthSelect = vi.fn();
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
@@ -417,10 +386,8 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame, stdin, unmount } = renderAuthDialog(
settings,
{},
{ handleAuthSelect },
const { lastFrame, stdin, unmount } = renderWithProviders(
<AuthDialog onSelect={onSelect} settings={settings} />,
);
await wait();
@@ -428,16 +395,16 @@ describe('AuthDialog', () => {
stdin.write('\u001b'); // ESC key
await wait();
// Should show error message instead of calling handleAuthSelect
// Should show error message instead of calling onSelect
expect(lastFrame()).toContain(
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
);
expect(handleAuthSelect).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
unmount();
});
it('should not exit if there is already an error message', async () => {
const handleAuthSelect = vi.fn();
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
@@ -471,10 +438,12 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame, stdin, unmount } = renderAuthDialog(
settings,
{ authError: 'Initial error' },
{ handleAuthSelect },
const { lastFrame, stdin, unmount } = renderWithProviders(
<AuthDialog
onSelect={onSelect}
settings={settings}
initialErrorMessage="Initial error"
/>,
);
await wait();
@@ -484,13 +453,13 @@ describe('AuthDialog', () => {
stdin.write('\u001b'); // ESC key
await wait();
// Should not call handleAuthSelect
expect(handleAuthSelect).not.toHaveBeenCalled();
// Should not call onSelect
expect(onSelect).not.toHaveBeenCalled();
unmount();
});
it('should allow exiting when auth method is already selected', async () => {
const handleAuthSelect = vi.fn();
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
@@ -524,10 +493,8 @@ describe('AuthDialog', () => {
new Set(),
);
const { stdin, unmount } = renderAuthDialog(
settings,
{},
{ handleAuthSelect },
const { stdin, unmount } = renderWithProviders(
<AuthDialog onSelect={onSelect} settings={settings} />,
);
await wait();
@@ -535,8 +502,8 @@ describe('AuthDialog', () => {
stdin.write('\u001b'); // ESC key
await wait();
// Should call handleAuthSelect with undefined to exit
expect(handleAuthSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
// Should call onSelect with undefined to exit
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
unmount();
});
});

View File

@@ -8,13 +8,23 @@ 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 {
setOpenAIApiKey,
setOpenAIBaseUrl,
setOpenAIModel,
validateAuthMethod,
} from '../../config/auth.js';
import { type LoadedSettings, SettingScope } from '../../config/settings.js';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { OpenAIKeyPrompt } from '../components/OpenAIKeyPrompt.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';
interface AuthDialogProps {
onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void;
settings: LoadedSettings;
initialErrorMessage?: string | null;
}
function parseDefaultAuthType(
defaultAuthType: string | undefined,
@@ -28,14 +38,15 @@ function parseDefaultAuthType(
return null;
}
export function AuthDialog(): React.JSX.Element {
const { pendingAuthType, authError } = useUIState();
const { handleAuthSelect: onAuthSelect } = useUIActions();
const settings = useSettings();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
export function AuthDialog({
onSelect,
settings,
initialErrorMessage,
}: AuthDialogProps): React.JSX.Element {
const [errorMessage, setErrorMessage] = useState<string | null>(
initialErrorMessage || null,
);
const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false);
const items = [
{
key: AuthType.QWEN_OAUTH,
@@ -48,17 +59,10 @@ export function AuthDialog(): React.JSX.Element {
const initialAuthIndex = Math.max(
0,
items.findIndex((item) => {
// Priority 1: pendingAuthType
if (pendingAuthType) {
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 3: QWEN_DEFAULT_AUTH_TYPE env var
const defaultAuthType = parseDefaultAuthType(
process.env['QWEN_DEFAULT_AUTH_TYPE'],
);
@@ -66,29 +70,55 @@ export function AuthDialog(): React.JSX.Element {
return item.value === defaultAuthType;
}
// Priority 4: default to QWEN_OAUTH
return item.value === AuthType.QWEN_OAUTH;
if (process.env['GEMINI_API_KEY']) {
return item.value === AuthType.USE_GEMINI;
}
return item.value === AuthType.LOGIN_WITH_GOOGLE;
}),
);
const hasApiKey = Boolean(settings.merged.security?.auth?.apiKey);
const currentSelectedAuthType =
selectedIndex !== null
? items[selectedIndex]?.value
: items[initialAuthIndex]?.value;
const handleAuthSelect = async (authMethod: AuthType) => {
setErrorMessage(null);
await onAuthSelect(authMethod, SettingScope.User);
const handleAuthSelect = (authMethod: AuthType) => {
const error = validateAuthMethod(authMethod);
if (error) {
if (
authMethod === AuthType.USE_OPENAI &&
!process.env['OPENAI_API_KEY']
) {
setShowOpenAIKeyPrompt(true);
setErrorMessage(null);
} else {
setErrorMessage(error);
}
} else {
setErrorMessage(null);
onSelect(authMethod, SettingScope.User);
}
};
const handleHighlight = (authMethod: AuthType) => {
const index = items.findIndex((item) => item.value === authMethod);
setSelectedIndex(index);
const handleOpenAIKeySubmit = (
apiKey: string,
baseUrl: string,
model: string,
) => {
setOpenAIApiKey(apiKey);
setOpenAIBaseUrl(baseUrl);
setOpenAIModel(model);
setShowOpenAIKeyPrompt(false);
onSelect(AuthType.USE_OPENAI, SettingScope.User);
};
const handleOpenAIKeyCancel = () => {
setShowOpenAIKeyPrompt(false);
setErrorMessage('OpenAI API key is required to use OpenAI authentication.');
};
useKeypress(
(key) => {
if (showOpenAIKeyPrompt) {
return;
}
if (key.name === 'escape') {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
@@ -102,12 +132,21 @@ export function AuthDialog(): React.JSX.Element {
);
return;
}
onAuthSelect(undefined, SettingScope.User);
onSelect(undefined, SettingScope.User);
}
},
{ isActive: true },
);
if (showOpenAIKeyPrompt) {
return (
<OpenAIKeyPrompt
onSubmit={handleOpenAIKeySubmit}
onCancel={handleOpenAIKeyCancel}
/>
);
}
return (
<Box
borderStyle="round"
@@ -125,26 +164,16 @@ export function AuthDialog(): React.JSX.Element {
items={items}
initialIndex={initialAuthIndex}
onSelect={handleAuthSelect}
onHighlight={handleHighlight}
/>
</Box>
{(authError || errorMessage) && (
{errorMessage && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{authError || errorMessage}</Text>
<Text color={Colors.AccentRed}>{errorMessage}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text>
</Box>
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
<Box marginTop={1}>
<Text color={Colors.Gray}>
Note: Your existing API key in settings.json will not be cleared
when using Qwen OAuth. You can switch back to OpenAI authentication
later if needed.
</Text>
</Box>
)}
<Box marginTop={1}>
<Text>Terms of Services and Privacy Notice for Qwen Code</Text>
</Box>

View File

@@ -6,169 +6,111 @@
import { useState, useCallback, useEffect } from 'react';
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { AuthType, type Config } from '@qwen-code/qwen-code-core';
import {
AuthType,
clearCachedCredentialFile,
getErrorMessage,
logAuth,
AuthEvent,
} from '@qwen-code/qwen-code-core';
import { runExitCleanup } from '../../utils/cleanup.js';
import { AuthState } from '../types.js';
import { useQwenAuth } from '../hooks/useQwenAuth.js';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
import { validateAuthMethod } from '../../config/auth.js';
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
export function validateAuthMethodWithSettings(
authType: AuthType,
settings: LoadedSettings,
): string | null {
const enforcedType = settings.merged.security?.auth?.enforcedType;
if (enforcedType && enforcedType !== authType) {
return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`;
}
if (settings.merged.security?.auth?.useExternal) {
return null;
}
return validateAuthMethod(authType);
}
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
const unAuthenticated =
settings.merged.security?.auth?.selectedType === undefined;
// If no auth type is selected, start in Updating state (shows auth dialog)
const [authState, setAuthState] = useState<AuthState>(
unAuthenticated ? AuthState.Updating : AuthState.Unauthenticated,
settings.merged.security?.auth?.selectedType === undefined
? AuthState.Updating
: AuthState.Unauthenticated,
);
const [authError, setAuthError] = useState<string | null>(null);
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated);
const [pendingAuthType, setPendingAuthType] = useState<AuthType | undefined>(
undefined,
);
const { qwenAuthState, cancelQwenAuth } = useQwenAuth(
pendingAuthType,
isAuthenticating,
);
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(false);
const onAuthError = useCallback(
(error: string | null) => {
setAuthError(error);
if (error) {
setAuthState(AuthState.Updating);
setIsAuthDialogOpen(true);
}
},
[setAuthError, setAuthState],
);
const handleAuthFailure = useCallback(
(error: unknown) => {
setIsAuthenticating(false);
const errorMessage = `Failed to authenticate. Message: ${getErrorMessage(error)}`;
onAuthError(errorMessage);
// Log authentication failure
if (pendingAuthType) {
const authEvent = new AuthEvent(
pendingAuthType,
'manual',
'error',
errorMessage,
);
logAuth(config, authEvent);
// Authentication flow
useEffect(() => {
const authFlow = async () => {
const authType = settings.merged.security?.auth?.selectedType;
if (isAuthDialogOpen || !authType) {
return;
}
const validationError = validateAuthMethodWithSettings(
authType,
settings,
);
if (validationError) {
onAuthError(validationError);
return;
}
},
[onAuthError, pendingAuthType, config],
);
const handleAuthSuccess = useCallback(
async (
authType: AuthType,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
try {
setIsAuthenticating(true);
await config.refreshAuth(authType);
console.log(`Authenticated via "${authType}".`);
setAuthError(null);
setAuthState(AuthState.Authenticated);
} catch (e) {
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
} finally {
setIsAuthenticating(false);
}
};
void authFlow();
}, [isAuthDialogOpen, settings, config, onAuthError]);
// Handle auth selection from dialog
const handleAuthSelect = useCallback(
async (authType: AuthType | undefined, scope: SettingScope) => {
if (authType) {
await clearCachedCredentialFile();
settings.setValue(scope, 'security.auth.selectedType', authType);
// 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,
'security.auth.apiKey',
credentials.apiKey,
);
}
if (credentials?.baseUrl != null) {
settings.setValue(
scope,
'security.auth.baseUrl',
credentials.baseUrl,
);
}
if (credentials?.model != null) {
settings.setValue(scope, 'model.name', credentials.model);
}
await clearCachedCredentialFile();
if (
authType === AuthType.LOGIN_WITH_GOOGLE &&
config.isBrowserLaunchSuppressed()
) {
await runExitCleanup();
console.log(`
----------------------------------------------------------------
Logging in with Google... Please restart Gemini CLI to continue.
----------------------------------------------------------------
`);
process.exit(0);
}
} catch (error) {
handleAuthFailure(error);
return;
}
setAuthError(null);
setAuthState(AuthState.Authenticated);
setPendingAuthType(undefined);
setIsAuthDialogOpen(false);
setIsAuthenticating(false);
// Log authentication success
const authEvent = new AuthEvent(authType, 'manual', 'success');
logAuth(config, authEvent);
},
[settings, handleAuthFailure, config],
);
const performAuth = useCallback(
async (
authType: AuthType,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
try {
await config.refreshAuth(authType);
handleAuthSuccess(authType, scope, credentials);
} catch (e) {
handleAuthFailure(e);
}
},
[config, handleAuthSuccess, handleAuthFailure],
);
const handleAuthSelect = useCallback(
async (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
if (!authType) {
setIsAuthDialogOpen(false);
setAuthError(null);
return;
}
setPendingAuthType(authType);
setAuthError(null);
setIsAuthDialogOpen(false);
setIsAuthenticating(true);
if (authType === AuthType.USE_OPENAI) {
if (credentials) {
config.updateCredentials({
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
});
await performAuth(authType, scope, credentials);
}
return;
}
await performAuth(authType, scope);
},
[config, performAuth],
[settings, config],
);
const openAuthDialog = useCallback(() => {
@@ -176,45 +118,8 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
}, []);
const cancelAuthentication = useCallback(() => {
if (isAuthenticating && pendingAuthType === AuthType.QWEN_OAUTH) {
cancelQwenAuth();
}
// Log authentication cancellation
if (isAuthenticating && pendingAuthType) {
const authEvent = new AuthEvent(pendingAuthType, 'manual', 'cancelled');
logAuth(config, authEvent);
}
// Do not reset pendingAuthType here, persist the previously selected type.
setIsAuthenticating(false);
setIsAuthDialogOpen(true);
setAuthError(null);
}, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]);
/**
/**
* We previously used a useEffect to trigger authentication automatically when
* settings.security.auth.selectedType changed. This caused problems: if authentication failed,
* the UI could get stuck, since settings.json would update before success. Now, we
* update selectedType in settings only when authentication fully succeeds.
* Authentication is triggered explicitly—either during initial app startup or when the
* user switches methods—not reactively through settings changes. This avoids repeated
* or broken authentication cycles.
*/
useEffect(() => {
const defaultAuthType = process.env['QWEN_DEFAULT_AUTH_TYPE'];
if (
defaultAuthType &&
![AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].includes(
defaultAuthType as AuthType,
)
) {
onAuthError(
`Invalid QWEN_DEFAULT_AUTH_TYPE value: "${defaultAuthType}". Valid values are: ${[AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', ')}`,
);
}
}, [onAuthError]);
}, []);
return {
authState,
@@ -223,8 +128,6 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
onAuthError,
isAuthDialogOpen,
isAuthenticating,
pendingAuthType,
qwenAuthState,
handleAuthSelect,
openAuthDialog,
cancelAuthentication,

View File

@@ -8,22 +8,38 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { aboutCommand } from './aboutCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import * as versionUtils from '../../utils/version.js';
import { MessageType } from '../types.js';
import * as systemInfoUtils from '../../utils/systemInfo.js';
import { IdeClient } from '@qwen-code/qwen-code-core';
vi.mock('../../utils/systemInfo.js');
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
IdeClient: {
getInstance: vi.fn().mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
}),
},
};
});
vi.mock('../../utils/version.js', () => ({
getCliVersion: vi.fn(),
}));
describe('aboutCommand', () => {
let mockContext: CommandContext;
const originalPlatform = process.platform;
const originalEnv = { ...process.env };
beforeEach(() => {
mockContext = createMockCommandContext({
services: {
config: {
getModel: vi.fn().mockReturnValue('test-model'),
getModel: vi.fn(),
getIdeMode: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
},
settings: {
merged: {
@@ -40,25 +56,21 @@ describe('aboutCommand', () => {
},
} as unknown as CommandContext);
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue(
'test-model',
);
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project';
Object.defineProperty(process, 'platform', {
value: 'test-os',
});
});
afterEach(() => {
vi.unstubAllEnvs();
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
process.env = originalEnv;
vi.clearAllMocks();
});
@@ -69,55 +81,30 @@ describe('aboutCommand', () => {
});
it('should call addItem with all version info', async () => {
process.env['SANDBOX'] = '';
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
await aboutCommand.action(mockContext, '');
expect(systemInfoUtils.getExtendedSystemInfo).toHaveBeenCalledWith(
mockContext,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
{
type: MessageType.ABOUT,
systemInfo: expect.objectContaining({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
}),
}),
cliVersion: 'test-version',
osVersion: 'test-os',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
gcpProject: 'test-gcp-project',
ideClient: 'test-ide',
},
expect.any(Number),
);
});
it('should show the correct sandbox environment variable', async () => {
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'gemini-sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
});
process.env['SANDBOX'] = 'gemini-sandbox';
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
@@ -126,32 +113,15 @@ describe('aboutCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ABOUT,
systemInfo: expect.objectContaining({
sandboxEnv: 'gemini-sandbox',
}),
sandboxEnv: 'gemini-sandbox',
}),
expect.any(Number),
);
});
it('should show sandbox-exec profile when applicable', async () => {
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'sandbox-exec (test-profile)',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
});
process.env['SANDBOX'] = 'sandbox-exec';
process.env['SEATBELT_PROFILE'] = 'test-profile';
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
@@ -160,31 +130,18 @@ describe('aboutCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
systemInfo: expect.objectContaining({
sandboxEnv: 'sandbox-exec (test-profile)',
}),
sandboxEnv: 'sandbox-exec (test-profile)',
}),
expect.any(Number),
);
});
it('should not show ide client when it is not detected', async () => {
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: '',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
});
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue(undefined),
} as unknown as IdeClient);
process.env['SANDBOX'] = '';
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
@@ -194,87 +151,13 @@ describe('aboutCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ABOUT,
systemInfo: expect.objectContaining({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: '',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
}),
}),
expect.any(Number),
);
});
it('should show unknown npmVersion when npm command fails', async () => {
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: 'unknown',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
});
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
await aboutCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
systemInfo: expect.objectContaining({
npmVersion: 'unknown',
}),
}),
expect.any(Number),
);
});
it('should show unknown sessionId when config is not available', async () => {
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'Unknown',
selectedAuthType: 'test-auth',
ideClient: '',
sessionId: 'unknown',
memoryUsage: '100 MB',
baseUrl: undefined,
});
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
await aboutCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
systemInfo: expect.objectContaining({
sessionId: 'unknown',
}),
cliVersion: 'test-version',
osVersion: 'test-os',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
gcpProject: 'test-gcp-project',
ideClient: '',
}),
expect.any(Number),
);

View File

@@ -4,23 +4,53 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand } from './types.js';
import { getCliVersion } from '../../utils/version.js';
import type { CommandContext, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import process from 'node:process';
import { MessageType, type HistoryItemAbout } from '../types.js';
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
import { IdeClient } from '@qwen-code/qwen-code-core';
export const aboutCommand: SlashCommand = {
name: 'about',
description: 'show version info',
kind: CommandKind.BUILT_IN,
action: async (context) => {
const systemInfo = await getExtendedSystemInfo(context);
const osVersion = process.platform;
let sandboxEnv = 'no sandbox';
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
sandboxEnv = process.env['SANDBOX'];
} else if (process.env['SANDBOX'] === 'sandbox-exec') {
sandboxEnv = `sandbox-exec (${
process.env['SEATBELT_PROFILE'] || 'unknown'
})`;
}
const modelVersion = context.services.config?.getModel() || 'Unknown';
const cliVersion = await getCliVersion();
const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
const ideClient = await getIdeClientName(context);
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
type: MessageType.ABOUT,
systemInfo,
cliVersion,
osVersion,
sandboxEnv,
modelVersion,
selectedAuthType,
gcpProject,
ideClient,
};
context.ui.addItem(aboutItem, Date.now());
},
};
async function getIdeClientName(context: CommandContext) {
if (!context.services.config?.getIdeMode()) {
return '';
}
const ideClient = await IdeClient.getInstance();
return ideClient?.getDetectedIdeDisplayName() ?? '';
}

View File

@@ -4,68 +4,492 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } 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';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
describe('approvalModeCommand', () => {
let mockContext: CommandContext;
let setApprovalModeMock: ReturnType<typeof vi.fn>;
let setSettingsValueMock: ReturnType<typeof vi.fn>;
const originalEnv = { ...process.env };
const userSettingsPath = '/mock/user/settings.json';
const projectSettingsPath = '/mock/project/settings.json';
const userSettingsFile = { path: userSettingsPath, settings: {} };
const projectSettingsFile = { path: projectSettingsPath, settings: {} };
const getModeSubCommand = (mode: ApprovalMode) =>
approvalModeCommand.subCommands?.find((cmd) => cmd.name === mode);
const getScopeSubCommand = (
mode: ApprovalMode,
scope: '--session' | '--user' | '--project',
) => getModeSubCommand(mode)?.subCommands?.find((cmd) => cmd.name === scope);
beforeEach(() => {
setApprovalModeMock = vi.fn();
setSettingsValueMock = vi.fn();
mockContext = createMockCommandContext({
services: {
config: {
getApprovalMode: () => 'default',
setApprovalMode: () => {},
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
setApprovalMode: setApprovalModeMock,
},
settings: {
merged: {},
setValue: () => {},
forScope: () => ({}),
setValue: setSettingsValueMock,
forScope: vi
.fn()
.mockImplementation((scope: SettingScope) =>
scope === SettingScope.User
? userSettingsFile
: scope === SettingScope.Workspace
? projectSettingsFile
: { path: '', settings: {} },
),
} as unknown as LoadedSettings,
},
});
} as unknown as CommandContext);
});
it('should have correct metadata', () => {
afterEach(() => {
process.env = { ...originalEnv };
vi.clearAllMocks();
});
it('should have the correct command properties', () => {
expect(approvalModeCommand.name).toBe('approval-mode');
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
expect(approvalModeCommand.description).toBe(
'View or change the approval mode for tool usage',
);
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should open approval mode dialog when invoked', async () => {
const result = (await approvalModeCommand.action?.(
it('should show current mode, options, and usage when no arguments provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'',
)) as OpenDialogActionReturn;
)) as MessageActionReturn;
expect(result.type).toBe('dialog');
expect(result.dialog).toBe('approval-mode');
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
const expectedMessage = [
'Current approval mode: default',
'',
'Available approval modes:',
' - plan: Plan mode - Analyze only, do not modify files or execute commands',
' - default: Default mode - Require approval for file edits or shell commands',
' - auto-edit: Auto-edit mode - Automatically approve file edits',
' - yolo: YOLO mode - Automatically approve all tools',
'',
'Usage: /approval-mode <mode> [--session|--user|--project]',
].join('\n');
expect(result.content).toBe(expectedMessage);
});
it('should open approval mode dialog with arguments (ignored)', async () => {
const result = (await approvalModeCommand.action?.(
it('should display error when config is not available', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const nullConfigContext = createMockCommandContext({
services: {
config: null,
},
} as unknown as CommandContext);
const result = (await approvalModeCommand.action(
nullConfigContext,
'',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toBe('Configuration not available.');
});
it('should change approval mode when valid mode is provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'some arguments',
)) as OpenDialogActionReturn;
'plan',
)) as MessageActionReturn;
expect(result.type).toBe('dialog');
expect(result.dialog).toBe('approval-mode');
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toBe('Approval mode changed to: plan');
});
it('should not have subcommands', () => {
expect(approvalModeCommand.subCommands).toBeUndefined();
it('should accept canonical auto-edit mode value', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'auto-edit',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toBe('Approval mode changed to: auto-edit');
});
it('should not have completion function', () => {
expect(approvalModeCommand.completion).toBeUndefined();
it('should accept auto-edit alias for compatibility', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'auto-edit',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.content).toBe('Approval mode changed to: auto-edit');
});
it('should display error when invalid mode is provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'invalid',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toContain('Invalid approval mode: invalid');
expect(result.content).toContain('Available approval modes:');
expect(result.content).toContain(
'Usage: /approval-mode <mode> [--session|--user|--project]',
);
});
it('should display error when setApprovalMode throws an error', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const errorMessage = 'Failed to set approval mode';
mockContext.services.config!.setApprovalMode = vi
.fn()
.mockImplementation(() => {
throw new Error(errorMessage);
});
const result = (await approvalModeCommand.action(
mockContext,
'plan',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toBe(
`Failed to change approval mode: ${errorMessage}`,
);
});
it('should allow selecting auto-edit with user scope via nested subcommands', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const userSubCommand = getScopeSubCommand(ApprovalMode.AUTO_EDIT, '--user');
if (!userSubCommand?.action) {
throw new Error('--user scope subcommand must have an action.');
}
const result = (await userSubCommand.action(
mockContext,
'',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'auto-edit',
);
expect(result.content).toBe(
`Approval mode changed to: auto-edit (saved to user settings at ${userSettingsPath})`,
);
});
it('should allow selecting plan with project scope via nested subcommands', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const projectSubCommand = getScopeSubCommand(
ApprovalMode.PLAN,
'--project',
);
if (!projectSubCommand?.action) {
throw new Error('--project scope subcommand must have an action.');
}
const result = (await projectSubCommand.action(
mockContext,
'',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.Workspace,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to project settings at ${projectSettingsPath})`,
);
});
it('should allow selecting plan with session scope via nested subcommands', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const sessionSubCommand = getScopeSubCommand(
ApprovalMode.PLAN,
'--session',
);
if (!sessionSubCommand?.action) {
throw new Error('--session scope subcommand must have an action.');
}
const result = (await sessionSubCommand.action(
mockContext,
'',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.content).toBe('Approval mode changed to: plan');
});
it('should allow providing a scope argument after selecting a mode subcommand', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const planSubCommand = getModeSubCommand(ApprovalMode.PLAN);
if (!planSubCommand?.action) {
throw new Error('plan subcommand must have an action.');
}
const result = (await planSubCommand.action(
mockContext,
'--user',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`,
);
});
it('should support --user plan pattern (scope first)', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--user plan',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`,
);
});
it('should support plan --user pattern (mode first)', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'plan --user',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`,
);
});
it('should support --project auto-edit pattern', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--project auto-edit',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.Workspace,
'approvalMode',
'auto-edit',
);
expect(result.content).toBe(
`Approval mode changed to: auto-edit (saved to project settings at ${projectSettingsPath})`,
);
});
it('should display error when only scope flag is provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--user',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toContain('Missing approval mode');
expect(setApprovalModeMock).not.toHaveBeenCalled();
expect(setSettingsValueMock).not.toHaveBeenCalled();
});
it('should display error when multiple scope flags are provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--user --project plan',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toContain('Multiple scope flags provided');
expect(setApprovalModeMock).not.toHaveBeenCalled();
expect(setSettingsValueMock).not.toHaveBeenCalled();
});
it('should surface a helpful error when scope subcommands receive extra arguments', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const userSubCommand = getScopeSubCommand(ApprovalMode.DEFAULT, '--user');
if (!userSubCommand?.action) {
throw new Error('--user scope subcommand must have an action.');
}
const result = (await userSubCommand.action(
mockContext,
'extra',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toBe(
'Scope subcommands do not accept additional arguments.',
);
expect(setApprovalModeMock).not.toHaveBeenCalled();
expect(setSettingsValueMock).not.toHaveBeenCalled();
});
it('should provide completion for approval modes', async () => {
if (!approvalModeCommand.completion) {
throw new Error('approvalModeCommand must have a completion function.');
}
// Test partial mode completion
const result = await approvalModeCommand.completion(mockContext, 'p');
expect(result).toEqual(['plan']);
const result2 = await approvalModeCommand.completion(mockContext, 'a');
expect(result2).toEqual(['auto-edit']);
// Test empty completion - should suggest available modes first
const result3 = await approvalModeCommand.completion(mockContext, '');
expect(result3).toEqual(['plan', 'default', 'auto-edit', 'yolo']);
const result4 = await approvalModeCommand.completion(mockContext, 'AUTO');
expect(result4).toEqual(['auto-edit']);
// Test mode first pattern: 'plan ' should suggest scope flags
const result5 = await approvalModeCommand.completion(mockContext, 'plan ');
expect(result5).toEqual(['--session', '--project', '--user']);
const result6 = await approvalModeCommand.completion(
mockContext,
'plan --u',
);
expect(result6).toEqual(['--user']);
// Test scope first pattern: '--user ' should suggest modes
const result7 = await approvalModeCommand.completion(
mockContext,
'--user ',
);
expect(result7).toEqual(['plan', 'default', 'auto-edit', 'yolo']);
const result8 = await approvalModeCommand.completion(
mockContext,
'--user p',
);
expect(result8).toEqual(['plan']);
// Test completed patterns should return empty
const result9 = await approvalModeCommand.completion(
mockContext,
'plan --user ',
);
expect(result9).toEqual([]);
const result10 = await approvalModeCommand.completion(
mockContext,
'--user plan ',
);
expect(result10).toEqual([]);
});
});

View File

@@ -7,19 +7,428 @@
import type {
SlashCommand,
CommandContext,
OpenDialogActionReturn,
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { ApprovalMode, APPROVAL_MODES } from '@qwen-code/qwen-code-core';
import { SettingScope } from '../../config/settings.js';
const USAGE_MESSAGE =
'Usage: /approval-mode <mode> [--session|--user|--project]';
const normalizeInputMode = (value: string): string =>
value.trim().toLowerCase();
const tokenizeArgs = (args: string): string[] => {
const matches = args.match(/(?:"[^"]*"|'[^']*'|[^\s"']+)/g);
if (!matches) {
return [];
}
return matches.map((token) => {
if (
(token.startsWith('"') && token.endsWith('"')) ||
(token.startsWith("'") && token.endsWith("'"))
) {
return token.slice(1, -1);
}
return token;
});
};
const parseApprovalMode = (value: string | null): ApprovalMode | null => {
if (!value) {
return null;
}
const normalized = normalizeInputMode(value).replace(/_/g, '-');
const matchIndex = APPROVAL_MODES.findIndex(
(candidate) => candidate === normalized,
);
return matchIndex === -1 ? null : APPROVAL_MODES[matchIndex];
};
const formatModeDescription = (mode: ApprovalMode): string => {
switch (mode) {
case ApprovalMode.PLAN:
return 'Plan mode - Analyze only, do not modify files or execute commands';
case ApprovalMode.DEFAULT:
return 'Default mode - Require approval for file edits or shell commands';
case ApprovalMode.AUTO_EDIT:
return 'Auto-edit mode - Automatically approve file edits';
case ApprovalMode.YOLO:
return 'YOLO mode - Automatically approve all tools';
default:
return `${mode} mode`;
}
};
const parseApprovalArgs = (
args: string,
): {
mode: string | null;
scope: 'session' | 'user' | 'project';
error?: string;
} => {
const trimmedArgs = args.trim();
if (!trimmedArgs) {
return { mode: null, scope: 'session' };
}
const tokens = tokenizeArgs(trimmedArgs);
let mode: string | null = null;
let scope: 'session' | 'user' | 'project' = 'session';
let scopeFlag: string | null = null;
// Find scope flag and mode
for (const token of tokens) {
if (token === '--session' || token === '--user' || token === '--project') {
if (scopeFlag) {
return {
mode: null,
scope: 'session',
error: 'Multiple scope flags provided',
};
}
scopeFlag = token;
scope = token.substring(2) as 'session' | 'user' | 'project';
} else if (!mode) {
mode = token;
} else {
return {
mode: null,
scope: 'session',
error: 'Invalid arguments provided',
};
}
}
if (!mode) {
return { mode: null, scope: 'session', error: 'Missing approval mode' };
}
return { mode, scope };
};
const setApprovalModeWithScope = async (
context: CommandContext,
mode: ApprovalMode,
scope: 'session' | 'user' | 'project',
): Promise<MessageActionReturn> => {
const { services } = context;
const { config } = services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
try {
// Always set the mode in the current session
config.setApprovalMode(mode);
// If scope is not session, also persist to settings
if (scope !== 'session') {
const { settings } = context.services;
if (!settings || typeof settings.setValue !== 'function') {
return {
type: 'message',
messageType: 'error',
content:
'Settings service is not available; unable to persist the approval mode.',
};
}
const settingScope =
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
const scopeLabel = scope === 'user' ? 'user' : 'project';
let settingsPath: string | undefined;
try {
if (typeof settings.forScope === 'function') {
settingsPath = settings.forScope(settingScope)?.path;
}
} catch (_error) {
settingsPath = undefined;
}
try {
settings.setValue(settingScope, 'approvalMode', mode);
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to save approval mode: ${(error as Error).message}`,
};
}
const locationSuffix = settingsPath ? ` at ${settingsPath}` : '';
const scopeSuffix = ` (saved to ${scopeLabel} settings${locationSuffix})`;
return {
type: 'message',
messageType: 'info',
content: `Approval mode changed to: ${mode}${scopeSuffix}`,
};
}
return {
type: 'message',
messageType: 'info',
content: `Approval mode changed to: ${mode}`,
};
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to change approval mode: ${(error as Error).message}`,
};
}
};
export const approvalModeCommand: SlashCommand = {
name: 'approval-mode',
description: 'View or change the approval mode for tool usage',
kind: CommandKind.BUILT_IN,
action: async (
_context: CommandContext,
_args: string,
): Promise<OpenDialogActionReturn> => ({
type: 'dialog',
dialog: 'approval-mode',
}),
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
// If no arguments provided, show current mode and available options
if (!args || args.trim() === '') {
const currentMode =
typeof config.getApprovalMode === 'function'
? config.getApprovalMode()
: null;
const messageLines: string[] = [];
if (currentMode) {
messageLines.push(`Current approval mode: ${currentMode}`);
messageLines.push('');
}
messageLines.push('Available approval modes:');
for (const mode of APPROVAL_MODES) {
messageLines.push(` - ${mode}: ${formatModeDescription(mode)}`);
}
messageLines.push('');
messageLines.push(USAGE_MESSAGE);
return {
type: 'message',
messageType: 'info',
content: messageLines.join('\n'),
};
}
// Parse arguments flexibly
const parsed = parseApprovalArgs(args);
if (parsed.error) {
return {
type: 'message',
messageType: 'error',
content: `${parsed.error}. ${USAGE_MESSAGE}`,
};
}
if (!parsed.mode) {
return {
type: 'message',
messageType: 'info',
content: USAGE_MESSAGE,
};
}
const requestedMode = parseApprovalMode(parsed.mode);
if (!requestedMode) {
let message = `Invalid approval mode: ${parsed.mode}\n\n`;
message += 'Available approval modes:\n';
for (const mode of APPROVAL_MODES) {
message += ` - ${mode}: ${formatModeDescription(mode)}\n`;
}
message += `\n${USAGE_MESSAGE}`;
return {
type: 'message',
messageType: 'error',
content: message,
};
}
return setApprovalModeWithScope(context, requestedMode, parsed.scope);
},
subCommands: APPROVAL_MODES.map((mode) => ({
name: mode,
description: formatModeDescription(mode),
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: '--session',
description: 'Apply to current session only (temporary)',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: 'Scope subcommands do not accept additional arguments.',
};
}
return setApprovalModeWithScope(context, mode, 'session');
},
},
{
name: '--project',
description: 'Persist for this project/workspace',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: 'Scope subcommands do not accept additional arguments.',
};
}
return setApprovalModeWithScope(context, mode, 'project');
},
},
{
name: '--user',
description: 'Persist for this user on this machine',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: 'Scope subcommands do not accept additional arguments.',
};
}
return setApprovalModeWithScope(context, mode, 'user');
},
},
],
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
// Allow users who type `/approval-mode plan --user` via the subcommand path
const parsed = parseApprovalArgs(`${mode} ${args}`);
if (parsed.error) {
return {
type: 'message',
messageType: 'error',
content: `${parsed.error}. ${USAGE_MESSAGE}`,
};
}
const normalizedMode = parseApprovalMode(parsed.mode);
if (!normalizedMode) {
return {
type: 'message',
messageType: 'error',
content: `Invalid approval mode: ${parsed.mode}. ${USAGE_MESSAGE}`,
};
}
return setApprovalModeWithScope(context, normalizedMode, parsed.scope);
}
return setApprovalModeWithScope(context, mode, 'session');
},
})),
completion: async (_context: CommandContext, partialArg: string) => {
const tokens = tokenizeArgs(partialArg);
const hasTrailingSpace = /\s$/.test(partialArg);
const currentSegment = hasTrailingSpace
? ''
: tokens.length > 0
? tokens[tokens.length - 1]
: '';
const normalizedCurrent = normalizeInputMode(currentSegment).replace(
/_/g,
'-',
);
const scopeValues = ['--session', '--project', '--user'];
const normalizeToken = (token: string) =>
normalizeInputMode(token).replace(/_/g, '-');
const normalizedTokens = tokens.map(normalizeToken);
if (tokens.length === 0) {
if (currentSegment.startsWith('-')) {
return scopeValues.filter((scope) => scope.startsWith(currentSegment));
}
return APPROVAL_MODES;
}
if (tokens.length === 1 && !hasTrailingSpace) {
const originalToken = tokens[0];
if (originalToken.startsWith('-')) {
return scopeValues.filter((scope) =>
scope.startsWith(normalizedCurrent),
);
}
return APPROVAL_MODES.filter((mode) =>
mode.startsWith(normalizedCurrent),
);
}
if (tokens.length === 1 && hasTrailingSpace) {
const normalizedFirst = normalizedTokens[0];
if (scopeValues.includes(tokens[0])) {
return APPROVAL_MODES;
}
if (APPROVAL_MODES.includes(normalizedFirst as ApprovalMode)) {
return scopeValues;
}
return APPROVAL_MODES;
}
if (tokens.length === 2 && !hasTrailingSpace) {
const normalizedFirst = normalizedTokens[0];
if (scopeValues.includes(tokens[0])) {
return APPROVAL_MODES.filter((mode) =>
mode.startsWith(normalizedCurrent),
);
}
if (APPROVAL_MODES.includes(normalizedFirst as ApprovalMode)) {
return scopeValues.filter((scope) =>
scope.startsWith(normalizedCurrent),
);
}
return [];
}
return [];
},
};

View File

@@ -8,34 +8,41 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import open from 'open';
import { bugCommand } from './bugCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { getCliVersion } from '../../utils/version.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import * as systemInfoUtils from '../../utils/systemInfo.js';
import { formatMemoryUsage } from '../utils/formatters.js';
// Mock dependencies
vi.mock('open');
vi.mock('../../utils/systemInfo.js');
vi.mock('../../utils/version.js');
vi.mock('../utils/formatters.js');
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
IdeClient: {
getInstance: () => ({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'),
}),
},
sessionId: 'test-session-id',
};
});
vi.mock('node:process', () => ({
default: {
platform: 'test-platform',
version: 'v20.0.0',
// Keep other necessary process properties if needed by other parts of the code
env: process.env,
memoryUsage: () => ({ rss: 0 }),
},
}));
describe('bugCommand', () => {
beforeEach(() => {
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: '0.1.0',
osPlatform: 'test-platform',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'test',
modelVersion: 'qwen3-coder-plus',
selectedAuthType: '',
ideClient: 'VSCode',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
gitCommit:
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? GIT_COMMIT_INFO
: undefined,
});
vi.mocked(getCliVersion).mockResolvedValue('0.1.0');
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
vi.stubEnv('SANDBOX', 'qwen-test');
});
@@ -48,7 +55,9 @@ describe('bugCommand', () => {
const mockContext = createMockCommandContext({
services: {
config: {
getModel: () => 'qwen3-coder-plus',
getBugCommand: () => undefined,
getIdeMode: () => true,
},
},
});
@@ -56,21 +65,13 @@ describe('bugCommand', () => {
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'A test bug');
const gitCommitLine =
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
: '';
const expectedInfo = `
* **CLI Version:** 0.1.0
${gitCommitLine}* **Model:** qwen3-coder-plus
* **Sandbox:** test
* **OS Platform:** test-platform
* **OS Arch:** x64
* **OS Release:** 22.0.0
* **Node.js Version:** v20.0.0
* **NPM Version:** 10.0.0
* **Git Commit:** ${GIT_COMMIT_INFO}
* **Session ID:** test-session-id
* **Auth Method:**
* **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test
* **Model Version:** qwen3-coder-plus
* **Memory Usage:** 100 MB
* **IDE Client:** VSCode
`;
@@ -87,7 +88,9 @@ ${gitCommitLine}* **Model:** qwen3-coder-plus
const mockContext = createMockCommandContext({
services: {
config: {
getModel: () => 'qwen3-coder-plus',
getBugCommand: () => ({ urlTemplate: customTemplate }),
getIdeMode: () => true,
},
},
});
@@ -95,21 +98,13 @@ ${gitCommitLine}* **Model:** qwen3-coder-plus
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'A custom bug');
const gitCommitLine =
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
: '';
const expectedInfo = `
* **CLI Version:** 0.1.0
${gitCommitLine}* **Model:** qwen3-coder-plus
* **Sandbox:** test
* **OS Platform:** test-platform
* **OS Arch:** x64
* **OS Release:** 22.0.0
* **Node.js Version:** v20.0.0
* **NPM Version:** 10.0.0
* **Git Commit:** ${GIT_COMMIT_INFO}
* **Session ID:** test-session-id
* **Auth Method:**
* **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test
* **Model Version:** qwen3-coder-plus
* **Memory Usage:** 100 MB
* **IDE Client:** VSCode
`;
@@ -119,62 +114,4 @@ ${gitCommitLine}* **Model:** qwen3-coder-plus
expect(open).toHaveBeenCalledWith(expectedUrl);
});
it('should include Base URL when auth type is OpenAI', async () => {
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: '0.1.0',
osPlatform: 'test-platform',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'test',
modelVersion: 'qwen3-coder-plus',
selectedAuthType: AuthType.USE_OPENAI,
ideClient: 'VSCode',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: 'https://api.openai.com/v1',
gitCommit:
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? GIT_COMMIT_INFO
: undefined,
});
const mockContext = createMockCommandContext({
services: {
config: {
getBugCommand: () => undefined,
},
},
});
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'OpenAI bug');
const gitCommitLine =
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
: '';
const expectedInfo = `
* **CLI Version:** 0.1.0
${gitCommitLine}* **Model:** qwen3-coder-plus
* **Sandbox:** test
* **OS Platform:** test-platform
* **OS Arch:** x64
* **OS Release:** 22.0.0
* **Node.js Version:** v20.0.0
* **NPM Version:** 10.0.0
* **Session ID:** test-session-id
* **Auth Method:** ${AuthType.USE_OPENAI}
* **Base URL:** https://api.openai.com/v1
* **Memory Usage:** 100 MB
* **IDE Client:** VSCode
`;
const expectedUrl =
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title=OpenAI%20bug&info=' +
encodeURIComponent(expectedInfo);
expect(open).toHaveBeenCalledWith(expectedUrl);
});
});

View File

@@ -5,17 +5,17 @@
*/
import open from 'open';
import process from 'node:process';
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import { MessageType } from '../types.js';
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
import {
getSystemInfoFields,
getFieldValue,
} from '../../utils/systemInfoFields.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import { getCliVersion } from '../../utils/version.js';
import { IdeClient, sessionId } from '@qwen-code/qwen-code-core';
export const bugCommand: SlashCommand = {
name: 'bug',
@@ -23,20 +23,39 @@ export const bugCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string): Promise<void> => {
const bugDescription = (args || '').trim();
const systemInfo = await getExtendedSystemInfo(context);
const { config } = context.services;
const fields = getSystemInfoFields(systemInfo);
const osVersion = `${process.platform} ${process.version}`;
let sandboxEnv = 'no sandbox';
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
sandboxEnv = process.env['SANDBOX'].replace(/^qwen-(?:code-)?/, '');
} else if (process.env['SANDBOX'] === 'sandbox-exec') {
sandboxEnv = `sandbox-exec (${
process.env['SEATBELT_PROFILE'] || 'unknown'
})`;
}
const modelVersion = config?.getModel() || 'Unknown';
const cliVersion = await getCliVersion();
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
const ideClient = await getIdeClientName(context);
// Generate bug report info using the same field configuration
let info = '\n';
for (const field of fields) {
info += `* **${field.label}:** ${getFieldValue(field, systemInfo)}\n`;
let info = `
* **CLI Version:** ${cliVersion}
* **Git Commit:** ${GIT_COMMIT_INFO}
* **Session ID:** ${sessionId}
* **Operating System:** ${osVersion}
* **Sandbox Environment:** ${sandboxEnv}
* **Model Version:** ${modelVersion}
* **Memory Usage:** ${memoryUsage}
`;
if (ideClient) {
info += `* **IDE Client:** ${ideClient}\n`;
}
let bugReportUrl =
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}';
const bugCommandSettings = context.services.config?.getBugCommand();
const bugCommandSettings = config?.getBugCommand();
if (bugCommandSettings?.urlTemplate) {
bugReportUrl = bugCommandSettings.urlTemplate;
}
@@ -68,3 +87,11 @@ export const bugCommand: SlashCommand = {
}
},
};
async function getIdeClientName(context: CommandContext) {
if (!context.services.config?.getIdeMode()) {
return '';
}
const ideClient = await IdeClient.getInstance();
return ideClient.getDetectedIdeDisplayName() ?? '';
}

View File

@@ -139,8 +139,8 @@ describe('chatCommand', () => {
.match(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
const formattedDate = isoDate ? `${isoDate[1]} ${isoDate[2]}` : '';
expect(content).toContain(formattedDate);
const index1 = content.indexOf('- test1');
const index2 = content.indexOf('- test2');
const index1 = content.indexOf('- \u001b[36mtest1\u001b[0m');
const index2 = content.indexOf('- \u001b[36mtest2\u001b[0m');
expect(index1).toBeGreaterThanOrEqual(0);
expect(index2).toBeGreaterThan(index1);
});

View File

@@ -89,9 +89,9 @@ const listCommand: SlashCommand = {
const isoString = chat.mtime.toISOString();
const match = isoString.match(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
const formattedDate = match ? `${match[1]} ${match[2]}` : 'Invalid Date';
message += ` - ${paddedName} (saved on ${formattedDate})\n`;
message += ` - \u001b[36m${paddedName}\u001b[0m \u001b[90m(saved on ${formattedDate})\u001b[0m\n`;
}
message += `\nNote: Newest last, oldest first`;
message += `\n\u001b[90mNote: Newest last, oldest first\u001b[0m`;
return {
type: 'message',
messageType: 'info',

View File

@@ -20,7 +20,6 @@ import {
MCPServerStatus,
getErrorMessage,
MCPOAuthTokenStorage,
MCPOAuthProvider,
} from '@qwen-code/qwen-code-core';
import { appEvents, AppEvent } from '../../utils/events.js';
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
@@ -94,6 +93,9 @@ const authCommand: SlashCommand = {
Date.now(),
);
// Import dynamically to avoid circular dependencies
const { MCPOAuthProvider } = await import('@qwen-code/qwen-code-core');
let oauthConfig = server.oauth;
if (!oauthConfig) {
oauthConfig = { enabled: false };

View File

@@ -17,7 +17,7 @@ import { terminalSetup } from '../utils/terminalSetup.js';
export const terminalSetupCommand: SlashCommand = {
name: 'terminal-setup',
description:
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)',
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf)',
kind: CommandKind.BUILT_IN,
action: async (): Promise<MessageActionReturn> => {

View File

@@ -129,8 +129,7 @@ export interface OpenDialogActionReturn {
| 'model'
| 'subagent_create'
| 'subagent_list'
| 'permissions'
| 'approval-mode';
| 'permissions';
}
/**

View File

@@ -7,46 +7,127 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { ExtendedSystemInfo } from '../../utils/systemInfo.js';
import {
getSystemInfoFields,
getFieldValue,
type SystemInfoField,
} from '../../utils/systemInfoFields.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
type AboutBoxProps = ExtendedSystemInfo;
interface AboutBoxProps {
cliVersion: string;
osVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
gcpProject: string;
ideClient: string;
}
export const AboutBox: React.FC<AboutBoxProps> = (props) => {
const fields = getSystemInfoFields(props);
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
marginY={1}
width="100%"
>
<Box marginBottom={1}>
<Text bold color={theme.text.accent}>
About Qwen Code
export const AboutBox: React.FC<AboutBoxProps> = ({
cliVersion,
osVersion,
sandboxEnv,
modelVersion,
selectedAuthType,
gcpProject,
ideClient,
}) => (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
marginY={1}
width="100%"
>
<Box marginBottom={1}>
<Text bold color={theme.text.accent}>
About Qwen Code
</Text>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
CLI Version
</Text>
</Box>
{fields.map((field: SystemInfoField) => (
<Box key={field.key} flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
{field.label}
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>
{getFieldValue(field, props)}
</Text>
</Box>
</Box>
))}
<Box>
<Text color={theme.text.primary}>{cliVersion}</Text>
</Box>
</Box>
);
};
{GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
Git Commit
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{GIT_COMMIT_INFO}</Text>
</Box>
</Box>
)}
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
Model
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{modelVersion}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
Sandbox
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{sandboxEnv}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
OS
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{osVersion}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
Auth Method
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>
{selectedAuthType.startsWith('oauth') ? 'OAuth' : selectedAuthType}
</Text>
</Box>
</Box>
{gcpProject && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
GCP Project
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{gcpProject}</Text>
</Box>
</Box>
)}
{ideClient && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
IDE Client
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{ideClient}</Text>
</Box>
</Box>
)}
</Box>
);

View File

@@ -1,183 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { ApprovalMode, APPROVAL_MODES } from '@qwen-code/qwen-code-core';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import type { LoadedSettings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { ScopeSelector } from './shared/ScopeSelector.js';
interface ApprovalModeDialogProps {
/** Callback function when an approval mode is selected */
onSelect: (mode: ApprovalMode | undefined, scope: SettingScope) => void;
/** The settings object */
settings: LoadedSettings;
/** Current approval mode */
currentMode: ApprovalMode;
/** Available terminal height for layout calculations */
availableTerminalHeight?: number;
}
const formatModeDescription = (mode: ApprovalMode): string => {
switch (mode) {
case ApprovalMode.PLAN:
return 'Analyze only, do not modify files or execute commands';
case ApprovalMode.DEFAULT:
return 'Require approval for file edits or shell commands';
case ApprovalMode.AUTO_EDIT:
return 'Automatically approve file edits';
case ApprovalMode.YOLO:
return 'Automatically approve all tools';
default:
return `${mode} mode`;
}
};
export function ApprovalModeDialog({
onSelect,
settings,
currentMode,
availableTerminalHeight: _availableTerminalHeight,
}: ApprovalModeDialogProps): React.JSX.Element {
// Start with User scope by default
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User,
);
// Track the currently highlighted approval mode
const [highlightedMode, setHighlightedMode] = useState<ApprovalMode>(
currentMode || ApprovalMode.DEFAULT,
);
// Generate approval mode items with inline descriptions
const modeItems = APPROVAL_MODES.map((mode) => ({
label: `${mode} - ${formatModeDescription(mode)}`,
value: mode,
key: mode,
}));
// Find the index of the current mode
const initialModeIndex = modeItems.findIndex(
(item) => item.value === highlightedMode,
);
const safeInitialModeIndex = initialModeIndex >= 0 ? initialModeIndex : 0;
const handleModeSelect = useCallback(
(mode: ApprovalMode) => {
onSelect(mode, selectedScope);
},
[onSelect, selectedScope],
);
const handleModeHighlight = (mode: ApprovalMode) => {
setHighlightedMode(mode);
};
const handleScopeHighlight = useCallback((scope: SettingScope) => {
setSelectedScope(scope);
}, []);
const handleScopeSelect = useCallback(
(scope: SettingScope) => {
onSelect(highlightedMode, scope);
},
[onSelect, highlightedMode],
);
const [focusSection, setFocusSection] = useState<'mode' | 'scope'>('mode');
useKeypress(
(key) => {
if (key.name === 'tab') {
setFocusSection((prev) => (prev === 'mode' ? 'scope' : 'mode'));
}
if (key.name === 'escape') {
onSelect(undefined, selectedScope);
}
},
{ isActive: true },
);
// Generate scope message for approval mode setting
const otherScopeModifiedMessage = getScopeMessageForSetting(
'tools.approvalMode',
selectedScope,
settings,
);
// Check if user scope is selected but workspace has the setting
const showWorkspacePriorityWarning =
selectedScope === SettingScope.User &&
otherScopeModifiedMessage.toLowerCase().includes('workspace');
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="row"
padding={1}
width="100%"
height="100%"
>
<Box flexDirection="column" flexGrow={1}>
{/* Approval Mode Selection */}
<Text bold={focusSection === 'mode'} wrap="truncate">
{focusSection === 'mode' ? '> ' : ' '}Approval Mode{' '}
<Text color={theme.text.secondary}>{otherScopeModifiedMessage}</Text>
</Text>
<Box height={1} />
<RadioButtonSelect
items={modeItems}
initialIndex={safeInitialModeIndex}
onSelect={handleModeSelect}
onHighlight={handleModeHighlight}
isFocused={focusSection === 'mode'}
maxItemsToShow={10}
showScrollArrows={false}
showNumbers={focusSection === 'mode'}
/>
<Box height={1} />
{/* Scope Selection */}
<Box marginTop={1}>
<ScopeSelector
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={focusSection === 'scope'}
initialScope={selectedScope}
/>
</Box>
<Box height={1} />
{/* Warning when workspace setting will override user setting */}
{showWorkspacePriorityWarning && (
<>
<Text color={theme.status.warning} wrap="wrap">
Workspace approval mode exists and takes priority. User-level
change will have no effect.
</Text>
<Box height={1} />
</>
)}
<Text color={theme.text.secondary}>
(Use Enter to select, Tab to change focus)
</Text>
</Box>
</Box>
);
}

View File

@@ -12,23 +12,19 @@ import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
import { ConsentPrompt } from './ConsentPrompt.js';
import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { AuthInProgress } from '../auth/AuthInProgress.js';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
import { ApprovalModeDialog } from './ApprovalModeDialog.js';
import { theme } from '../semantic-colors.js';
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';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
@@ -59,16 +55,6 @@ export const DialogManager = ({
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
uiState;
const getDefaultOpenAIConfig = () => {
const fromSettings = settings.merged.security?.auth;
const modelSettings = settings.merged.model;
return {
apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '',
baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '',
model: modelSettings?.name || process.env['OPENAI_MODEL'] || '',
};
};
if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) {
return (
<WelcomeBackDialog
@@ -194,22 +180,6 @@ export const DialogManager = ({
onSelect={() => uiActions.closeSettingsDialog()}
onRestartRequest={() => process.exit(0)}
availableTerminalHeight={terminalHeight - staticExtraHeight}
config={config}
/>
</Box>
);
}
if (uiState.isApprovalModeDialogOpen) {
const currentMode = config.getApprovalMode();
return (
<Box flexDirection="column">
<ApprovalModeDialog
settings={settings}
currentMode={currentMode}
onSelect={uiActions.handleApprovalModeSelect}
availableTerminalHeight={
constrainHeight ? terminalHeight - staticExtraHeight : undefined
}
/>
</Box>
);
@@ -220,56 +190,39 @@ export const DialogManager = ({
if (uiState.isVisionSwitchDialogOpen) {
return <ModelSwitchDialog onSelect={uiActions.handleVisionSwitchSelect} />;
}
if (uiState.isAuthDialogOpen || uiState.authError) {
return (
<Box flexDirection="column">
<AuthDialog />
</Box>
);
}
if (uiState.isAuthenticating) {
if (uiState.pendingAuthType === AuthType.USE_OPENAI) {
const defaults = getDefaultOpenAIConfig();
return (
<OpenAIKeyPrompt
onSubmit={(apiKey, baseUrl, model) => {
uiActions.handleAuthSelect(AuthType.USE_OPENAI, SettingScope.User, {
apiKey,
baseUrl,
model,
});
}}
onCancel={() => {
uiActions.cancelAuthentication();
uiActions.setAuthState(AuthState.Updating);
}}
defaultApiKey={defaults.apiKey}
defaultBaseUrl={defaults.baseUrl}
defaultModel={defaults.model}
/>
);
}
if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) {
// Show Qwen OAuth progress if it's Qwen auth and OAuth is active
if (uiState.isQwenAuth && uiState.isQwenAuthenticating) {
return (
<QwenOAuthProgress
deviceAuth={uiState.qwenAuthState.deviceAuth || undefined}
authStatus={uiState.qwenAuthState.authStatus}
authMessage={uiState.qwenAuthState.authMessage}
onTimeout={() => {
uiActions.onAuthError('Qwen OAuth authentication timed out.');
uiActions.cancelAuthentication();
uiActions.setAuthState(AuthState.Updating);
}}
onCancel={() => {
uiActions.cancelAuthentication();
uiActions.setAuthState(AuthState.Updating);
}}
deviceAuth={uiState.deviceAuth || undefined}
authStatus={uiState.authStatus}
authMessage={uiState.authMessage}
onTimeout={uiActions.handleQwenAuthTimeout}
onCancel={uiActions.handleQwenAuthCancel}
/>
);
}
// Default auth progress for other auth types
return (
<AuthInProgress
onTimeout={() => {
uiActions.onAuthError('Authentication cancelled.');
}}
/>
);
}
if (uiState.isAuthDialogOpen) {
return (
<Box flexDirection="column">
<AuthDialog
onSelect={uiActions.handleAuthSelect}
settings={settings}
initialErrorMessage={uiState.authError}
/>
</Box>
);
}
if (uiState.isEditorDialogOpen) {
return (

View File

@@ -71,24 +71,15 @@ describe('<HistoryItemDisplay />', () => {
it('renders AboutBox for "about" type', () => {
const item: HistoryItem = {
id: 1,
...baseItem,
type: MessageType.ABOUT,
systemInfo: {
cliVersion: '1.0.0',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'test-env',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
gitCommit: undefined,
},
cliVersion: '1.0.0',
osVersion: 'test-os',
sandboxEnv: 'test-env',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
gcpProject: 'test-project',
ideClient: 'test-ide',
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,

View File

@@ -95,7 +95,15 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
<ErrorMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'about' && (
<AboutBox {...itemForDisplay.systemInfo} />
<AboutBox
cliVersion={itemForDisplay.cliVersion}
osVersion={itemForDisplay.osVersion}
sandboxEnv={itemForDisplay.sandboxEnv}
modelVersion={itemForDisplay.modelVersion}
selectedAuthType={itemForDisplay.selectedAuthType}
gcpProject={itemForDisplay.gcpProject}
ideClient={itemForDisplay.ideClient}
/>
)}
{itemForDisplay.type === 'help' && commands && (
<Help commands={commands} />

View File

@@ -6,7 +6,6 @@
import type React from 'react';
import { useState } from 'react';
import { z } from 'zod';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -14,62 +13,18 @@ import { useKeypress } from '../hooks/useKeypress.js';
interface OpenAIKeyPromptProps {
onSubmit: (apiKey: string, baseUrl: string, model: string) => void;
onCancel: () => void;
defaultApiKey?: string;
defaultBaseUrl?: string;
defaultModel?: string;
}
export const credentialSchema = z.object({
apiKey: z.string().min(1, 'API key is required'),
baseUrl: z
.union([z.string().url('Base URL must be a valid URL'), z.literal('')])
.optional(),
model: z.string().min(1, 'Model must be a non-empty string').optional(),
});
export type OpenAICredentials = z.infer<typeof credentialSchema>;
export function OpenAIKeyPrompt({
onSubmit,
onCancel,
defaultApiKey,
defaultBaseUrl,
defaultModel,
}: OpenAIKeyPromptProps): React.JSX.Element {
const [apiKey, setApiKey] = useState(defaultApiKey || '');
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl || '');
const [model, setModel] = useState(defaultModel || '');
const [apiKey, setApiKey] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [model, setModel] = useState('');
const [currentField, setCurrentField] = useState<
'apiKey' | 'baseUrl' | 'model'
>('apiKey');
const [validationError, setValidationError] = useState<string | null>(null);
const validateAndSubmit = () => {
setValidationError(null);
try {
const validated = credentialSchema.parse({
apiKey: apiKey.trim(),
baseUrl: baseUrl.trim() || undefined,
model: model.trim() || undefined,
});
onSubmit(
validated.apiKey,
validated.baseUrl === '' ? '' : validated.baseUrl || '',
validated.model || '',
);
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessage = error.errors
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ');
setValidationError(`Invalid credentials: ${errorMessage}`);
} else {
setValidationError('Failed to validate credentials');
}
}
};
useKeypress(
(key) => {
@@ -91,7 +46,7 @@ export function OpenAIKeyPrompt({
} else if (currentField === 'model') {
// 只有在提交时才检查 API key 是否为空
if (apiKey.trim()) {
validateAndSubmit();
onSubmit(apiKey.trim(), baseUrl.trim(), model.trim());
} else {
// 如果 API key 为空,回到 API key 字段
setCurrentField('apiKey');
@@ -175,7 +130,7 @@ export function OpenAIKeyPrompt({
}
// Handle regular character input
if (key.sequence && !key.ctrl && !key.meta) {
if (key.sequence && !key.ctrl && !key.meta && !key.name) {
// Filter control characters
const cleanInput = key.sequence
.split('')
@@ -207,11 +162,6 @@ export function OpenAIKeyPrompt({
<Text bold color={Colors.AccentBlue}>
OpenAI Configuration Required
</Text>
{validationError && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{validationError}</Text>
</Box>
)}
<Box marginTop={1}>
<Text>
Please enter your OpenAI configuration. You can get an API key from{' '}

View File

@@ -8,7 +8,7 @@
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
import { useKeypress } from '../hooks/useKeypress.js';
import type { Key } from '../contexts/KeypressContext.js';
@@ -42,13 +42,12 @@ describe('QwenOAuthProgress', () => {
let keypressHandler: ((key: Key) => void) | null = null;
const createMockDeviceAuth = (
overrides: Partial<DeviceAuthorizationData> = {},
): DeviceAuthorizationData => ({
overrides: Partial<DeviceAuthorizationInfo> = {},
): DeviceAuthorizationInfo => ({
verification_uri: 'https://example.com/device',
verification_uri_complete: 'https://example.com/device?user_code=ABC123',
user_code: 'ABC123',
expires_in: 300,
device_code: 'test-device-code',
...overrides,
});
@@ -56,7 +55,7 @@ describe('QwenOAuthProgress', () => {
const renderComponent = (
props: Partial<{
deviceAuth: DeviceAuthorizationData;
deviceAuth: DeviceAuthorizationInfo;
authStatus:
| 'idle'
| 'polling'
@@ -159,7 +158,7 @@ describe('QwenOAuthProgress', () => {
});
it('should format time correctly', () => {
const deviceAuthWithCustomTime: DeviceAuthorizationData = {
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = {
...mockDeviceAuth,
expires_in: 125, // 2 minutes and 5 seconds
};
@@ -177,7 +176,7 @@ describe('QwenOAuthProgress', () => {
});
it('should format single digit seconds with leading zero', () => {
const deviceAuthWithCustomTime: DeviceAuthorizationData = {
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = {
...mockDeviceAuth,
expires_in: 67, // 1 minute and 7 seconds
};
@@ -197,7 +196,7 @@ describe('QwenOAuthProgress', () => {
describe('Timer functionality', () => {
it('should countdown and call onTimeout when timer expires', async () => {
const deviceAuthWithShortTime: DeviceAuthorizationData = {
const deviceAuthWithShortTime: DeviceAuthorizationInfo = {
...mockDeviceAuth,
expires_in: 2, // 2 seconds
};
@@ -521,7 +520,7 @@ describe('QwenOAuthProgress', () => {
describe('Props changes', () => {
it('should display initial timer value from deviceAuth', () => {
const deviceAuthWith10Min: DeviceAuthorizationData = {
const deviceAuthWith10Min: DeviceAuthorizationInfo = {
...mockDeviceAuth,
expires_in: 600, // 10 minutes
};

View File

@@ -11,13 +11,13 @@ import Spinner from 'ink-spinner';
import Link from 'ink-link';
import qrcode from 'qrcode-terminal';
import { Colors } from '../colors.js';
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface QwenOAuthProgressProps {
onTimeout: () => void;
onCancel: () => void;
deviceAuth?: DeviceAuthorizationData;
deviceAuth?: DeviceAuthorizationInfo;
authStatus?:
| 'idle'
| 'polling'
@@ -131,8 +131,8 @@ export function QwenOAuthProgress({
useKeypress(
(key) => {
if (authStatus === 'timeout' || authStatus === 'error') {
// Any key press in timeout or error state should trigger cancel to return to auth dialog
if (authStatus === 'timeout') {
// Any key press in timeout state should trigger cancel to return to auth dialog
onCancel();
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
onCancel();
@@ -234,35 +234,6 @@ export function QwenOAuthProgress({
);
}
if (authStatus === 'error') {
return (
<Box
borderStyle="round"
borderColor={Colors.AccentRed}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentRed}>
Qwen OAuth Authentication Error
</Text>
<Box marginTop={1}>
<Text>
{authMessage ||
'An error occurred during authentication. Please try again.'}
</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
Press any key to return to authentication type selection.
</Text>
</Box>
</Box>
);
}
// Show loading state when no device auth is available yet
if (!deviceAuth) {
return (

View File

@@ -487,11 +487,8 @@ describe('SettingsDialog', () => {
it('loops back when reaching the end of an enum', async () => {
vi.mocked(saveModifiedSettings).mockClear();
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
const settings = createMockSettings({
ui: {
theme: StringEnum.BAZ,
},
});
const settings = createMockSettings();
settings.setValue(SettingScope.User, 'ui.theme', StringEnum.BAZ);
const onSelect = vi.fn();
const component = (
<KeypressProvider kittyProtocolEnabled={false}>

View File

@@ -9,8 +9,11 @@ import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { LoadedSettings, Settings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
import { ScopeSelector } from './shared/ScopeSelector.js';
import {
getScopeItems,
getScopeMessageForSetting,
} from '../../utils/dialogScopeUtils.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import {
getDialogSettingKeys,
setPendingSettingValue,
@@ -27,7 +30,6 @@ import {
getEffectiveValue,
} from '../../utils/settingsUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { type Config } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js';
import chalk from 'chalk';
import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
@@ -41,7 +43,6 @@ interface SettingsDialogProps {
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
onRestartRequest?: () => void;
availableTerminalHeight?: number;
config?: Config;
}
const maxItemsToShow = 8;
@@ -51,7 +52,6 @@ export function SettingsDialog({
onSelect,
onRestartRequest,
availableTerminalHeight,
config,
}: SettingsDialogProps): React.JSX.Element {
// Get vim mode context to sync vim mode changes
const { vimEnabled, toggleVimEnabled } = useVimMode();
@@ -184,21 +184,6 @@ export function SettingsDialog({
});
}
// Special handling for approval mode to apply to current session
if (
key === 'tools.approvalMode' &&
settings.merged.tools?.approvalMode
) {
try {
config?.setApprovalMode(settings.merged.tools.approvalMode);
} catch (error) {
console.error(
'Failed to apply approval mode to current session:',
error,
);
}
}
// Remove from modifiedSettings since it's now saved
setModifiedSettings((prev) => {
const updated = new Set(prev);
@@ -372,6 +357,12 @@ export function SettingsDialog({
setEditCursorPos(0);
};
// Scope selector items
const scopeItems = getScopeItems().map((item) => ({
...item,
key: item.value,
}));
const handleScopeHighlight = (scope: SettingScope) => {
setSelectedScope(scope);
};
@@ -625,11 +616,7 @@ export function SettingsDialog({
prev,
),
);
} else if (
defType === 'number' ||
defType === 'string' ||
defType === 'enum'
) {
} else if (defType === 'number' || defType === 'string') {
if (
typeof defaultValue === 'number' ||
typeof defaultValue === 'string'
@@ -686,21 +673,6 @@ export function SettingsDialog({
selectedScope,
);
// Special handling for approval mode to apply to current session
if (
currentSetting.value === 'tools.approvalMode' &&
settings.merged.tools?.approvalMode
) {
try {
config?.setApprovalMode(settings.merged.tools.approvalMode);
} catch (error) {
console.error(
'Failed to apply approval mode to current session:',
error,
);
}
}
// Remove from global pending changes if present
setGlobalPendingChanges((prev) => {
if (!prev.has(currentSetting.value)) return prev;
@@ -904,12 +876,19 @@ export function SettingsDialog({
{/* Scope Selection - conditionally visible based on height constraints */}
{showScopeSelection && (
<Box marginTop={1}>
<ScopeSelector
<Box marginTop={1} flexDirection="column">
<Text bold={focusSection === 'scope'} wrap="truncate">
{focusSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={scopeItems.findIndex(
(item) => item.value === selectedScope,
)}
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={focusSection === 'scope'}
initialScope={selectedScope}
showNumbers={focusSection === 'scope'}
/>
</Box>
)}

View File

@@ -28,6 +28,7 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ │
@@ -62,6 +63,7 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ │
@@ -96,6 +98,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ │
@@ -130,6 +133,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ │
@@ -164,6 +168,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ │
@@ -198,6 +203,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ │
@@ -232,6 +238,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ │
@@ -266,6 +273,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ │
@@ -300,6 +308,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ │
@@ -334,6 +343,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ System Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ │

View File

@@ -6,6 +6,7 @@ exports[`ThemeDialog Snapshots > should render correctly in scope selector mode
│ > Apply To │
│ ● 1. User Settings │
│ 2. Workspace Settings │
│ 3. System Settings │
│ │
│ (Use Enter to apply scope, Tab to select theme) │
│ │

View File

@@ -47,7 +47,7 @@ export function CompressionMessage({
case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR:
return 'Could not compress chat history due to a token counting error.';
case CompressionStatus.NOOP:
return 'Nothing to compress.';
return 'Chat history is already compressed.';
default:
return '';
}

View File

@@ -12,7 +12,6 @@ import type {
Config,
} from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import type { LoadedSettings } from '../../../config/settings.js';
describe('ToolConfirmationMessage', () => {
const mockConfig = {
@@ -188,63 +187,4 @@ describe('ToolConfirmationMessage', () => {
});
});
});
describe('external editor option', () => {
const editConfirmationDetails: ToolCallConfirmationDetails = {
type: 'edit',
title: 'Confirm Edit',
fileName: 'test.txt',
filePath: '/test.txt',
fileDiff: '...diff...',
originalContent: 'a',
newContent: 'b',
onConfirm: vi.fn(),
};
it('should show "Modify with external editor" when preferredEditor is set', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={editConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
{
settings: {
merged: { general: { preferredEditor: 'vscode' } },
} as unknown as LoadedSettings,
},
);
expect(lastFrame()).toContain('Modify with external editor');
});
it('should NOT show "Modify with external editor" when preferredEditor is not set', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={editConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
{
settings: {
merged: { general: {} },
} as unknown as LoadedSettings,
},
);
expect(lastFrame()).not.toContain('Modify with external editor');
});
});
});

View File

@@ -15,14 +15,12 @@ import type {
ToolExecuteConfirmationDetails,
ToolMcpConfirmationDetails,
Config,
EditorType,
} from '@qwen-code/qwen-code-core';
import { IdeClient, ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { theme } from '../../semantic-colors.js';
export interface ToolConfirmationMessageProps {
@@ -47,11 +45,6 @@ export const ToolConfirmationMessage: React.FC<
const { onConfirm } = confirmationDetails;
const childWidth = terminalWidth - 2; // 2 for padding
const settings = useSettings();
const preferredEditor = settings.merged.general?.preferredEditor as
| EditorType
| undefined;
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
@@ -206,7 +199,7 @@ export const ToolConfirmationMessage: React.FC<
key: 'Yes, allow always',
});
}
if ((!config.getIdeMode() || !isDiffingEnabled) && preferredEditor) {
if (!config.getIdeMode() || !isDiffingEnabled) {
options.push({
label: 'Modify with external editor',
value: ToolConfirmationOutcome.ModifyWithEditor,

View File

@@ -23,7 +23,7 @@ export const ToolsList: React.FC<ToolsListProps> = ({
}) => (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
Available Qwen Code CLI tools:
Available Gemini CLI tools:
</Text>
<Box height={1} />
{tools.length > 0 ? (

View File

@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolsList /> > renders correctly with descriptions 1`] = `
"Available Qwen Code CLI tools:
"Available Gemini CLI tools:
- Test Tool One (test-tool-one)
This is the first test tool.
@@ -16,14 +16,14 @@ exports[`<ToolsList /> > renders correctly with descriptions 1`] = `
`;
exports[`<ToolsList /> > renders correctly with no tools 1`] = `
"Available Qwen Code CLI tools:
"Available Gemini CLI tools:
No tools available
"
`;
exports[`<ToolsList /> > renders correctly without descriptions 1`] = `
"Available Qwen Code CLI tools:
"Available Gemini CLI tools:
- Test Tool One
- Test Tool Two

View File

@@ -8,15 +8,10 @@ import { createContext, useContext } from 'react';
import { type Key } from '../hooks/useKeypress.js';
import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js';
import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
import {
type AuthType,
type EditorType,
type ApprovalMode,
} from '@qwen-code/qwen-code-core';
import { type AuthType, type EditorType } from '@qwen-code/qwen-code-core';
import { type SettingScope } from '../../config/settings.js';
import type { AuthState } from '../types.js';
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
import { type OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
export interface UIActions {
handleThemeSelect: (
@@ -24,18 +19,15 @@ export interface UIActions {
scope: SettingScope,
) => void;
handleThemeHighlight: (themeName: string | undefined) => void;
handleApprovalModeSelect: (
mode: ApprovalMode | undefined,
scope: SettingScope,
) => void;
handleAuthSelect: (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: OpenAICredentials,
) => Promise<void>;
) => void;
setAuthState: (state: AuthState) => void;
onAuthError: (error: string) => void;
cancelAuthentication: () => void;
// Qwen OAuth handlers
handleQwenAuthTimeout: () => void;
handleQwenAuthCancel: () => void;
handleEditorSelect: (
editorType: EditorType | undefined,
scope: SettingScope,

View File

@@ -16,11 +16,10 @@ import type {
HistoryItemWithoutId,
StreamingState,
} from '../types.js';
import type { QwenAuthState } from '../hooks/useQwenAuth.js';
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import type {
AuthType,
IdeContext,
ApprovalMode,
UserTierId,
@@ -50,9 +49,18 @@ export interface UIState {
isConfigInitialized: boolean;
authError: string | null;
isAuthDialogOpen: boolean;
pendingAuthType: AuthType | undefined;
// Qwen OAuth state
qwenAuthState: QwenAuthState;
isQwenAuth: boolean;
isQwenAuthenticating: boolean;
deviceAuth: DeviceAuthorizationInfo | null;
authStatus:
| 'idle'
| 'polling'
| 'success'
| 'error'
| 'timeout'
| 'rate_limit';
authMessage: string | null;
editorError: string | null;
isEditorDialogOpen: boolean;
corgiMode: boolean;
@@ -61,7 +69,6 @@ export interface UIState {
isSettingsDialogOpen: boolean;
isModelDialogOpen: boolean;
isPermissionsDialogOpen: boolean;
isApprovalModeDialogOpen: boolean;
slashCommands: readonly SlashCommand[];
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
commandContext: CommandContext;

View File

@@ -25,7 +25,6 @@ export const EDITOR_DISPLAY_NAMES: Record<EditorType, string> = {
vscodium: 'VSCodium',
windsurf: 'Windsurf',
zed: 'Zed',
trae: 'Trae',
};
class EditorSettingsManager {

View File

@@ -80,8 +80,6 @@ describe('handleAtCommand', () => {
getReadManyFilesExcludes: () => [],
}),
getUsageStatisticsEnabled: () => false,
getTruncateToolOutputThreshold: () => 2500,
getTruncateToolOutputLines: () => 500,
} as unknown as Config;
const registry = new ToolRegistry(mockConfig);

View File

@@ -48,7 +48,6 @@ interface SlashCommandProcessorActions {
openSettingsDialog: () => void;
openModelDialog: () => void;
openPermissionsDialog: () => void;
openApprovalModeDialog: () => void;
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
toggleCorgiMode: () => void;
@@ -139,7 +138,13 @@ export const useSlashCommandProcessor = (
if (message.type === MessageType.ABOUT) {
historyItemContent = {
type: 'about',
systemInfo: message.systemInfo,
cliVersion: message.cliVersion,
osVersion: message.osVersion,
sandboxEnv: message.sandboxEnv,
modelVersion: message.modelVersion,
selectedAuthType: message.selectedAuthType,
gcpProject: message.gcpProject,
ideClient: message.ideClient,
};
} else if (message.type === MessageType.HELP) {
historyItemContent = {
@@ -397,9 +402,6 @@ export const useSlashCommandProcessor = (
case 'subagent_list':
actions.openAgentsManagerDialog();
return { type: 'handled' };
case 'approval-mode':
actions.openApprovalModeDialog();
return { type: 'handled' };
case 'help':
return { type: 'handled' };
default: {

View File

@@ -1,57 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
import type { ApprovalMode, Config } from '@qwen-code/qwen-code-core';
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
interface UseApprovalModeCommandReturn {
isApprovalModeDialogOpen: boolean;
openApprovalModeDialog: () => void;
handleApprovalModeSelect: (
mode: ApprovalMode | undefined,
scope: SettingScope,
) => void;
}
export const useApprovalModeCommand = (
loadedSettings: LoadedSettings,
config: Config,
): UseApprovalModeCommandReturn => {
const [isApprovalModeDialogOpen, setIsApprovalModeDialogOpen] =
useState(false);
const openApprovalModeDialog = useCallback(() => {
setIsApprovalModeDialogOpen(true);
}, []);
const handleApprovalModeSelect = useCallback(
(mode: ApprovalMode | undefined, scope: SettingScope) => {
try {
if (!mode) {
// User cancelled the dialog
setIsApprovalModeDialogOpen(false);
return;
}
// Set the mode in the current session and persist to settings
loadedSettings.setValue(scope, 'tools.approvalMode', mode);
config.setApprovalMode(
loadedSettings.merged.tools?.approvalMode ?? mode,
);
} finally {
setIsApprovalModeDialogOpen(false);
}
},
[config, loadedSettings],
);
return {
isApprovalModeDialogOpen,
openApprovalModeDialog,
handleApprovalModeSelect,
};
};

View File

@@ -1,151 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { StreamingState } from '../types.js';
import {
AttentionNotificationReason,
notifyTerminalAttention,
} from '../../utils/attentionNotification.js';
import {
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS,
useAttentionNotifications,
} from './useAttentionNotifications.js';
vi.mock('../../utils/attentionNotification.js', () => ({
notifyTerminalAttention: vi.fn(),
AttentionNotificationReason: {
ToolApproval: 'tool_approval',
LongTaskComplete: 'long_task_complete',
},
}));
const mockedNotify = vi.mocked(notifyTerminalAttention);
describe('useAttentionNotifications', () => {
beforeEach(() => {
mockedNotify.mockReset();
});
const render = (
props?: Partial<Parameters<typeof useAttentionNotifications>[0]>,
) =>
renderHook(({ hookProps }) => useAttentionNotifications(hookProps), {
initialProps: {
hookProps: {
isFocused: true,
streamingState: StreamingState.Idle,
elapsedTime: 0,
...props,
},
},
});
it('notifies when tool approval is required while unfocused', () => {
const { rerender } = render();
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.ToolApproval,
);
});
it('notifies when focus is lost after entering approval wait state', () => {
const { rerender } = render({
isFocused: true,
streamingState: StreamingState.WaitingForConfirmation,
});
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
},
});
expect(mockedNotify).toHaveBeenCalledTimes(1);
});
it('sends a notification when a long task finishes while unfocused', () => {
const { rerender } = render();
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.Responding,
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5,
},
});
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.Idle,
elapsedTime: 0,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.LongTaskComplete,
);
});
it('does not notify about long tasks when the CLI is focused', () => {
const { rerender } = render();
rerender({
hookProps: {
isFocused: true,
streamingState: StreamingState.Responding,
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2,
},
});
rerender({
hookProps: {
isFocused: true,
streamingState: StreamingState.Idle,
elapsedTime: 0,
},
});
expect(mockedNotify).not.toHaveBeenCalledWith(
AttentionNotificationReason.LongTaskComplete,
expect.anything(),
);
});
it('does not treat short responses as long tasks', () => {
const { rerender } = render();
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.Responding,
elapsedTime: 5,
},
});
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.Idle,
elapsedTime: 0,
},
});
expect(mockedNotify).not.toHaveBeenCalled();
});
});

View File

@@ -1,63 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useRef } from 'react';
import { StreamingState } from '../types.js';
import {
notifyTerminalAttention,
AttentionNotificationReason,
} from '../../utils/attentionNotification.js';
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
interface UseAttentionNotificationsOptions {
isFocused: boolean;
streamingState: StreamingState;
elapsedTime: number;
}
export const useAttentionNotifications = ({
isFocused,
streamingState,
elapsedTime,
}: UseAttentionNotificationsOptions) => {
const awaitingNotificationSentRef = useRef(false);
const respondingElapsedRef = useRef(0);
useEffect(() => {
if (
streamingState === StreamingState.WaitingForConfirmation &&
!isFocused &&
!awaitingNotificationSentRef.current
) {
notifyTerminalAttention(AttentionNotificationReason.ToolApproval);
awaitingNotificationSentRef.current = true;
}
if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) {
awaitingNotificationSentRef.current = false;
}
}, [isFocused, streamingState]);
useEffect(() => {
if (streamingState === StreamingState.Responding) {
respondingElapsedRef.current = elapsedTime;
return;
}
if (streamingState === StreamingState.Idle) {
const wasLongTask =
respondingElapsedRef.current >=
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS;
if (wasLongTask && !isFocused) {
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete);
}
// Reset tracking for next task
respondingElapsedRef.current = 0;
return;
}
}, [streamingState, elapsedTime, isFocused]);
};

View File

@@ -6,29 +6,20 @@
import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js';
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
import type { AuthType } from '@qwen-code/qwen-code-core';
export interface DialogCloseOptions {
// Theme dialog
isThemeDialogOpen: boolean;
handleThemeSelect: (theme: string | undefined, scope: SettingScope) => void;
// Approval mode dialog
isApprovalModeDialogOpen: boolean;
handleApprovalModeSelect: (
mode: ApprovalMode | undefined,
scope: SettingScope,
) => void;
// Auth dialog
isAuthDialogOpen: boolean;
handleAuthSelect: (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: OpenAICredentials,
) => Promise<void>;
pendingAuthType: AuthType | undefined;
selectedAuthType: AuthType | undefined;
// Editor dialog
isEditorDialogOpen: boolean;
@@ -66,12 +57,6 @@ export function useDialogClose(options: DialogCloseOptions) {
return true;
}
if (options.isApprovalModeDialogOpen) {
// Mimic ESC behavior: onSelect(undefined, selectedScope) - keeps current mode
options.handleApprovalModeSelect(undefined, SettingScope.User);
return true;
}
if (options.isEditorDialogOpen) {
// Mimic ESC behavior: call onExit() directly
options.exitEditorDialog();

View File

@@ -109,7 +109,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope,
'general.preferredEditor',
'preferredEditor',
editorType,
);
@@ -139,7 +139,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope,
'general.preferredEditor',
'preferredEditor',
undefined,
);
@@ -170,7 +170,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope,
'general.preferredEditor',
'preferredEditor',
editorType,
);
@@ -199,7 +199,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope,
'general.preferredEditor',
'preferredEditor',
editorType,
);

View File

@@ -45,7 +45,7 @@ export const useEditorSettings = (
}
try {
loadedSettings.setValue(scope, 'general.preferredEditor', editorType);
loadedSettings.setValue(scope, 'preferredEditor', editorType);
addItem(
{
type: MessageType.INFO,

View File

@@ -1,47 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useRef } from 'react';
/**
* Hook that handles initialization authentication error only once.
* This ensures that if an auth error occurred during app initialization,
* it is reported to the user exactly once, even if the component re-renders.
*
* @param authError - The authentication error from initialization, or null if no error.
* @param onAuthError - Callback function to handle the authentication error.
*
* @example
* ```tsx
* useInitializationAuthError(
* initializationResult.authError,
* onAuthError
* );
* ```
*/
export const useInitializationAuthError = (
authError: string | null,
onAuthError: (error: string) => void,
): void => {
const hasHandled = useRef(false);
const authErrorRef = useRef(authError);
const onAuthErrorRef = useRef(onAuthError);
// Update refs to always use latest values
authErrorRef.current = authError;
onAuthErrorRef.current = onAuthError;
useEffect(() => {
if (hasHandled.current) {
return;
}
if (authErrorRef.current) {
hasHandled.current = true;
onAuthErrorRef.current(authErrorRef.current);
}
}, [authError, onAuthError]);
};

View File

@@ -6,13 +6,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
import type { DeviceAuthorizationInfo } from './useQwenAuth.js';
import { useQwenAuth } from './useQwenAuth.js';
import {
AuthType,
qwenOAuth2Events,
QwenOAuth2Event,
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
// Mock the qwenOAuth2Events
vi.mock('@qwen-code/qwen-code-core', async () => {
@@ -35,14 +36,24 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
const mockQwenOAuth2Events = vi.mocked(qwenOAuth2Events);
describe('useQwenAuth', () => {
const mockDeviceAuth: DeviceAuthorizationData = {
const mockDeviceAuth: DeviceAuthorizationInfo = {
verification_uri: 'https://oauth.qwen.com/device',
verification_uri_complete: 'https://oauth.qwen.com/device?user_code=ABC123',
user_code: 'ABC123',
expires_in: 1800,
device_code: 'device_code_123',
};
const createMockSettings = (authType: AuthType): LoadedSettings =>
({
merged: {
security: {
auth: {
selectedType: authType,
},
},
},
}) as LoadedSettings;
beforeEach(() => {
vi.clearAllMocks();
});
@@ -52,33 +63,36 @@ describe('useQwenAuth', () => {
});
it('should initialize with default state when not Qwen auth', () => {
const { result } = renderHook(() =>
useQwenAuth(AuthType.USE_GEMINI, false),
);
const settings = createMockSettings(AuthType.USE_GEMINI);
const { result } = renderHook(() => useQwenAuth(settings, false));
expect(result.current.qwenAuthState).toEqual({
expect(result.current).toEqual({
isQwenAuthenticating: false,
deviceAuth: null,
authStatus: 'idle',
authMessage: null,
isQwenAuth: false,
cancelQwenAuth: expect.any(Function),
});
expect(result.current.cancelQwenAuth).toBeInstanceOf(Function);
});
it('should initialize with default state when Qwen auth but not authenticating', () => {
const { result } = renderHook(() =>
useQwenAuth(AuthType.QWEN_OAUTH, false),
);
const settings = createMockSettings(AuthType.QWEN_OAUTH);
const { result } = renderHook(() => useQwenAuth(settings, false));
expect(result.current.qwenAuthState).toEqual({
expect(result.current).toEqual({
isQwenAuthenticating: false,
deviceAuth: null,
authStatus: 'idle',
authMessage: null,
isQwenAuth: true,
cancelQwenAuth: expect.any(Function),
});
expect(result.current.cancelQwenAuth).toBeInstanceOf(Function);
});
it('should set up event listeners when Qwen auth and authenticating', () => {
renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const settings = createMockSettings(AuthType.QWEN_OAUTH);
renderHook(() => useQwenAuth(settings, true));
expect(mockQwenOAuth2Events.on).toHaveBeenCalledWith(
QwenOAuth2Event.AuthUri,
@@ -91,7 +105,8 @@ describe('useQwenAuth', () => {
});
it('should handle device auth event', () => {
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) {
@@ -100,17 +115,19 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events;
});
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
act(() => {
handleDeviceAuth!(mockDeviceAuth);
});
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.qwenAuthState.authStatus).toBe('polling');
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.authStatus).toBe('polling');
expect(result.current.isQwenAuthenticating).toBe(true);
});
it('should handle auth progress event - success', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string,
@@ -123,19 +140,18 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events;
});
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
act(() => {
handleAuthProgress!('success', 'Authentication successful!');
});
expect(result.current.qwenAuthState.authStatus).toBe('success');
expect(result.current.qwenAuthState.authMessage).toBe(
'Authentication successful!',
);
expect(result.current.authStatus).toBe('success');
expect(result.current.authMessage).toBe('Authentication successful!');
});
it('should handle auth progress event - error', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string,
@@ -148,19 +164,18 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events;
});
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
act(() => {
handleAuthProgress!('error', 'Authentication failed');
});
expect(result.current.qwenAuthState.authStatus).toBe('error');
expect(result.current.qwenAuthState.authMessage).toBe(
'Authentication failed',
);
expect(result.current.authStatus).toBe('error');
expect(result.current.authMessage).toBe('Authentication failed');
});
it('should handle auth progress event - polling', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string,
@@ -173,19 +188,20 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events;
});
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
act(() => {
handleAuthProgress!('polling', 'Waiting for user authorization...');
});
expect(result.current.qwenAuthState.authStatus).toBe('polling');
expect(result.current.qwenAuthState.authMessage).toBe(
expect(result.current.authStatus).toBe('polling');
expect(result.current.authMessage).toBe(
'Waiting for user authorization...',
);
});
it('should handle auth progress event - rate_limit', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string,
@@ -198,7 +214,7 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events;
});
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
act(() => {
handleAuthProgress!(
@@ -207,13 +223,14 @@ describe('useQwenAuth', () => {
);
});
expect(result.current.qwenAuthState.authStatus).toBe('rate_limit');
expect(result.current.qwenAuthState.authMessage).toBe(
expect(result.current.authStatus).toBe('rate_limit');
expect(result.current.authMessage).toBe(
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
);
});
it('should handle auth progress event without message', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleAuthProgress: (
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
message?: string,
@@ -226,30 +243,27 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events;
});
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
act(() => {
handleAuthProgress!('success');
});
expect(result.current.qwenAuthState.authStatus).toBe('success');
expect(result.current.qwenAuthState.authMessage).toBe(null);
expect(result.current.authStatus).toBe('success');
expect(result.current.authMessage).toBe(null);
});
it('should clean up event listeners when auth type changes', () => {
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
const { rerender } = renderHook(
({ pendingAuthType, isAuthenticating }) =>
useQwenAuth(pendingAuthType, isAuthenticating),
{
initialProps: {
pendingAuthType: AuthType.QWEN_OAUTH,
isAuthenticating: true,
},
},
({ settings, isAuthenticating }) =>
useQwenAuth(settings, isAuthenticating),
{ initialProps: { settings: qwenSettings, isAuthenticating: true } },
);
// Change to non-Qwen auth
rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true });
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
rerender({ settings: geminiSettings, isAuthenticating: true });
expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith(
QwenOAuth2Event.AuthUri,
@@ -262,9 +276,9 @@ describe('useQwenAuth', () => {
});
it('should clean up event listeners when authentication stops', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
const { rerender } = renderHook(
({ isAuthenticating }) =>
useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating),
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating),
{ initialProps: { isAuthenticating: true } },
);
@@ -282,9 +296,8 @@ describe('useQwenAuth', () => {
});
it('should clean up event listeners on unmount', () => {
const { unmount } = renderHook(() =>
useQwenAuth(AuthType.QWEN_OAUTH, true),
);
const settings = createMockSettings(AuthType.QWEN_OAUTH);
const { unmount } = renderHook(() => useQwenAuth(settings, true));
unmount();
@@ -299,7 +312,8 @@ describe('useQwenAuth', () => {
});
it('should reset state when switching from Qwen auth to another auth type', () => {
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) {
@@ -309,14 +323,9 @@ describe('useQwenAuth', () => {
});
const { result, rerender } = renderHook(
({ pendingAuthType, isAuthenticating }) =>
useQwenAuth(pendingAuthType, isAuthenticating),
{
initialProps: {
pendingAuthType: AuthType.QWEN_OAUTH,
isAuthenticating: true,
},
},
({ settings, isAuthenticating }) =>
useQwenAuth(settings, isAuthenticating),
{ initialProps: { settings: qwenSettings, isAuthenticating: true } },
);
// Simulate device auth
@@ -324,19 +333,22 @@ describe('useQwenAuth', () => {
handleDeviceAuth!(mockDeviceAuth);
});
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.qwenAuthState.authStatus).toBe('polling');
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.authStatus).toBe('polling');
// Switch to different auth type
rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true });
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
rerender({ settings: geminiSettings, isAuthenticating: true });
expect(result.current.qwenAuthState.deviceAuth).toBe(null);
expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(result.current.qwenAuthState.authMessage).toBe(null);
expect(result.current.isQwenAuthenticating).toBe(false);
expect(result.current.deviceAuth).toBe(null);
expect(result.current.authStatus).toBe('idle');
expect(result.current.authMessage).toBe(null);
});
it('should reset state when authentication stops', () => {
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) {
@@ -346,8 +358,7 @@ describe('useQwenAuth', () => {
});
const { result, rerender } = renderHook(
({ isAuthenticating }) =>
useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating),
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating),
{ initialProps: { isAuthenticating: true } },
);
@@ -356,19 +367,21 @@ describe('useQwenAuth', () => {
handleDeviceAuth!(mockDeviceAuth);
});
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.qwenAuthState.authStatus).toBe('polling');
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.authStatus).toBe('polling');
// Stop authentication
rerender({ isAuthenticating: false });
expect(result.current.qwenAuthState.deviceAuth).toBe(null);
expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(result.current.qwenAuthState.authMessage).toBe(null);
expect(result.current.isQwenAuthenticating).toBe(false);
expect(result.current.deviceAuth).toBe(null);
expect(result.current.authStatus).toBe('idle');
expect(result.current.authMessage).toBe(null);
});
it('should handle cancelQwenAuth function', () => {
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
const settings = createMockSettings(AuthType.QWEN_OAUTH);
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
if (event === QwenOAuth2Event.AuthUri) {
@@ -377,49 +390,53 @@ describe('useQwenAuth', () => {
return mockQwenOAuth2Events;
});
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
const { result } = renderHook(() => useQwenAuth(settings, true));
// Set up some state
act(() => {
handleDeviceAuth!(mockDeviceAuth);
});
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
// Cancel auth
act(() => {
result.current.cancelQwenAuth();
});
expect(result.current.qwenAuthState.deviceAuth).toBe(null);
expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(result.current.qwenAuthState.authMessage).toBe(null);
expect(result.current.isQwenAuthenticating).toBe(false);
expect(result.current.deviceAuth).toBe(null);
expect(result.current.authStatus).toBe('idle');
expect(result.current.authMessage).toBe(null);
});
it('should handle different auth types correctly', () => {
// Test with Qwen OAuth - should set up event listeners when authenticating
it('should maintain isQwenAuth flag correctly', () => {
// Test with Qwen OAuth
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
const { result: qwenResult } = renderHook(() =>
useQwenAuth(AuthType.QWEN_OAUTH, true),
useQwenAuth(qwenSettings, false),
);
expect(qwenResult.current.qwenAuthState.authStatus).toBe('idle');
expect(mockQwenOAuth2Events.on).toHaveBeenCalled();
expect(qwenResult.current.isQwenAuth).toBe(true);
// Test with other auth types - should not set up event listeners
// Test with other auth types
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
const { result: geminiResult } = renderHook(() =>
useQwenAuth(AuthType.USE_GEMINI, true),
useQwenAuth(geminiSettings, false),
);
expect(geminiResult.current.qwenAuthState.authStatus).toBe('idle');
expect(geminiResult.current.isQwenAuth).toBe(false);
const oauthSettings = createMockSettings(AuthType.LOGIN_WITH_GOOGLE);
const { result: oauthResult } = renderHook(() =>
useQwenAuth(AuthType.LOGIN_WITH_GOOGLE, true),
useQwenAuth(oauthSettings, false),
);
expect(oauthResult.current.qwenAuthState.authStatus).toBe('idle');
expect(oauthResult.current.isQwenAuth).toBe(false);
});
it('should initialize with idle status when starting authentication with Qwen auth', () => {
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
it('should set isQwenAuthenticating to true when starting authentication with Qwen auth', () => {
const settings = createMockSettings(AuthType.QWEN_OAUTH);
const { result } = renderHook(() => useQwenAuth(settings, true));
expect(result.current.qwenAuthState.authStatus).toBe('idle');
expect(mockQwenOAuth2Events.on).toHaveBeenCalled();
expect(result.current.isQwenAuthenticating).toBe(true);
expect(result.current.authStatus).toBe('idle');
});
});

View File

@@ -5,15 +5,23 @@
*/
import { useState, useCallback, useEffect } from 'react';
import type { LoadedSettings } from '../../config/settings.js';
import {
AuthType,
qwenOAuth2Events,
QwenOAuth2Event,
type DeviceAuthorizationData,
} from '@qwen-code/qwen-code-core';
export interface QwenAuthState {
deviceAuth: DeviceAuthorizationData | null;
export interface DeviceAuthorizationInfo {
verification_uri: string;
verification_uri_complete: string;
user_code: string;
expires_in: number;
}
interface QwenAuthState {
isQwenAuthenticating: boolean;
deviceAuth: DeviceAuthorizationInfo | null;
authStatus:
| 'idle'
| 'polling'
@@ -25,22 +33,25 @@ export interface QwenAuthState {
}
export const useQwenAuth = (
pendingAuthType: AuthType | undefined,
settings: LoadedSettings,
isAuthenticating: boolean,
) => {
const [qwenAuthState, setQwenAuthState] = useState<QwenAuthState>({
isQwenAuthenticating: false,
deviceAuth: null,
authStatus: 'idle',
authMessage: null,
});
const isQwenAuth = pendingAuthType === AuthType.QWEN_OAUTH;
const isQwenAuth =
settings.merged.security?.auth?.selectedType === AuthType.QWEN_OAUTH;
// Set up event listeners when authentication starts
useEffect(() => {
if (!isQwenAuth || !isAuthenticating) {
// Reset state when not authenticating or not Qwen auth
setQwenAuthState({
isQwenAuthenticating: false,
deviceAuth: null,
authStatus: 'idle',
authMessage: null,
@@ -50,11 +61,12 @@ export const useQwenAuth = (
setQwenAuthState((prev) => ({
...prev,
isQwenAuthenticating: true,
authStatus: 'idle',
}));
// Set up event listeners
const handleDeviceAuth = (deviceAuth: DeviceAuthorizationData) => {
const handleDeviceAuth = (deviceAuth: DeviceAuthorizationInfo) => {
setQwenAuthState((prev) => ({
...prev,
deviceAuth: {
@@ -62,7 +74,6 @@ export const useQwenAuth = (
verification_uri_complete: deviceAuth.verification_uri_complete,
user_code: deviceAuth.user_code,
expires_in: deviceAuth.expires_in,
device_code: deviceAuth.device_code,
},
authStatus: 'polling',
}));
@@ -95,6 +106,7 @@ export const useQwenAuth = (
qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel);
setQwenAuthState({
isQwenAuthenticating: false,
deviceAuth: null,
authStatus: 'idle',
authMessage: null,
@@ -102,7 +114,8 @@ export const useQwenAuth = (
}, []);
return {
qwenAuthState,
...qwenAuthState,
isQwenAuth,
cancelQwenAuth,
};
};

View File

@@ -120,22 +120,13 @@ export type HistoryItemWarning = HistoryItemBase & {
export type HistoryItemAbout = HistoryItemBase & {
type: 'about';
systemInfo: {
cliVersion: string;
osPlatform: string;
osArch: string;
osRelease: string;
nodeVersion: string;
npmVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
ideClient: string;
sessionId: string;
memoryUsage: string;
baseUrl?: string;
gitCommit?: string;
};
cliVersion: string;
osVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
gcpProject: string;
ideClient: string;
};
export type HistoryItemHelp = HistoryItemBase & {
@@ -297,22 +288,13 @@ export type Message =
| {
type: MessageType.ABOUT;
timestamp: Date;
systemInfo: {
cliVersion: string;
osPlatform: string;
osArch: string;
osRelease: string;
nodeVersion: string;
npmVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
ideClient: string;
sessionId: string;
memoryUsage: string;
baseUrl?: string;
gitCommit?: string;
};
cliVersion: string;
osVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
gcpProject: string;
ideClient: string;
content?: string; // Optional content, not really used for ABOUT
}
| {

View File

@@ -48,7 +48,7 @@ export interface TerminalSetupResult {
requiresRestart?: boolean;
}
type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf' | 'trae';
type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf';
// Terminal detection
async function detectTerminal(): Promise<SupportedTerminal | null> {
@@ -68,11 +68,6 @@ async function detectTerminal(): Promise<SupportedTerminal | null> {
) {
return 'windsurf';
}
if (process.env['TERM_PRODUCT']?.toLowerCase().includes('trae')) {
return 'trae';
}
// Check VS Code last since forks may also set VSCODE env vars
if (termProgram === 'vscode' || process.env['VSCODE_GIT_IPC_HANDLE']) {
return 'vscode';
@@ -91,8 +86,6 @@ async function detectTerminal(): Promise<SupportedTerminal | null> {
return 'cursor';
if (parentName.includes('code') || parentName.includes('Code'))
return 'vscode';
if (parentName.includes('trae') || parentName.includes('Trae'))
return 'trae';
} catch (error) {
// Continue detection even if process check fails
console.debug('Parent process detection failed:', error);
@@ -294,10 +287,6 @@ async function configureWindsurf(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Windsurf', 'Windsurf');
}
async function configureTrae(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Trae', 'Trae');
}
/**
* Main terminal setup function that detects and configures the current terminal.
*
@@ -344,8 +333,6 @@ export async function terminalSetup(): Promise<TerminalSetupResult> {
return configureCursor();
case 'windsurf':
return configureWindsurf();
case 'trae':
return configureTrae();
default:
return {
success: false,

View File

@@ -1,72 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
notifyTerminalAttention,
AttentionNotificationReason,
} from './attentionNotification.js';
describe('notifyTerminalAttention', () => {
let stream: { write: ReturnType<typeof vi.fn>; isTTY: boolean };
beforeEach(() => {
stream = { write: vi.fn().mockReturnValue(true), isTTY: true };
});
it('emits terminal bell character', () => {
const result = notifyTerminalAttention(
AttentionNotificationReason.ToolApproval,
{
stream,
},
);
expect(result).toBe(true);
expect(stream.write).toHaveBeenCalledWith('\u0007');
});
it('returns false when not running inside a tty', () => {
stream.isTTY = false;
const result = notifyTerminalAttention(
AttentionNotificationReason.ToolApproval,
{ stream },
);
expect(result).toBe(false);
expect(stream.write).not.toHaveBeenCalled();
});
it('returns false when stream write fails', () => {
stream.write = vi.fn().mockImplementation(() => {
throw new Error('Write failed');
});
const result = notifyTerminalAttention(
AttentionNotificationReason.ToolApproval,
{ stream },
);
expect(result).toBe(false);
});
it('works with different notification reasons', () => {
const reasons = [
AttentionNotificationReason.ToolApproval,
AttentionNotificationReason.LongTaskComplete,
];
reasons.forEach((reason) => {
stream.write.mockClear();
const result = notifyTerminalAttention(reason, { stream });
expect(result).toBe(true);
expect(stream.write).toHaveBeenCalledWith('\u0007');
});
});
});

View File

@@ -1,43 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
export enum AttentionNotificationReason {
ToolApproval = 'tool_approval',
LongTaskComplete = 'long_task_complete',
}
export interface TerminalNotificationOptions {
stream?: Pick<NodeJS.WriteStream, 'write' | 'isTTY'>;
}
const TERMINAL_BELL = '\u0007';
/**
* Grabs the user's attention by emitting the terminal bell character.
* This causes the terminal to flash or play a sound, alerting the user
* to check the CLI for important events.
*
* @returns true when the bell was successfully written to the terminal.
*/
export function notifyTerminalAttention(
_reason: AttentionNotificationReason,
options: TerminalNotificationOptions = {},
): boolean {
const stream = options.stream ?? process.stdout;
if (!stream?.write || stream.isTTY === false) {
return false;
}
try {
stream.write(TERMINAL_BELL);
return true;
} catch (error) {
console.warn('Failed to send terminal bell:', error);
return false;
}
}

View File

@@ -14,11 +14,7 @@ import { settingExistsInScope } from './settingsUtils.js';
export const SCOPE_LABELS = {
[SettingScope.User]: 'User Settings',
[SettingScope.Workspace]: 'Workspace Settings',
// TODO: migrate system settings to user settings
// we don't want to save settings to system scope, it is a troublemaker
// comment it out for now.
// [SettingScope.System]: 'System Settings',
[SettingScope.System]: 'System Settings',
} as const;
/**
@@ -31,7 +27,7 @@ export function getScopeItems() {
label: SCOPE_LABELS[SettingScope.Workspace],
value: SettingScope.Workspace,
},
// { label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System },
{ label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System },
];
}

View File

@@ -1,331 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import {
getSystemInfo,
getExtendedSystemInfo,
getNpmVersion,
getSandboxEnv,
getIdeClientName,
} from './systemInfo.js';
import type { CommandContext } from '../ui/commands/types.js';
import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
import * as child_process from 'node:child_process';
import os from 'node:os';
import { IdeClient } from '@qwen-code/qwen-code-core';
import * as versionUtils from './version.js';
import type { ExecSyncOptions } from 'node:child_process';
vi.mock('node:child_process');
vi.mock('node:os', () => ({
default: {
release: vi.fn(),
},
}));
vi.mock('./version.js', () => ({
getCliVersion: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
IdeClient: {
getInstance: vi.fn(),
},
};
});
describe('systemInfo', () => {
let mockContext: CommandContext;
const originalPlatform = process.platform;
const originalArch = process.arch;
const originalVersion = process.version;
const originalEnv = { ...process.env };
beforeEach(() => {
mockContext = createMockCommandContext({
services: {
config: {
getModel: vi.fn().mockReturnValue('test-model'),
getIdeMode: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getContentGeneratorConfig: vi.fn().mockReturnValue({
baseUrl: 'https://api.openai.com',
}),
},
settings: {
merged: {
security: {
auth: {
selectedType: 'test-auth',
},
},
},
},
},
} as unknown as CommandContext);
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
vi.mocked(os.release).mockReturnValue('22.0.0');
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project';
Object.defineProperty(process, 'platform', {
value: 'test-os',
});
Object.defineProperty(process, 'arch', {
value: 'x64',
});
Object.defineProperty(process, 'version', {
value: 'v20.0.0',
});
});
afterEach(() => {
vi.unstubAllEnvs();
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
Object.defineProperty(process, 'arch', {
value: originalArch,
});
Object.defineProperty(process, 'version', {
value: originalVersion,
});
process.env = originalEnv;
vi.clearAllMocks();
vi.resetAllMocks();
});
describe('getNpmVersion', () => {
it('should return npm version when available', async () => {
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
const version = await getNpmVersion();
expect(version).toBe('10.0.0');
});
it('should return unknown when npm command fails', async () => {
vi.mocked(child_process.execSync).mockImplementation(() => {
throw new Error('npm not found');
});
const version = await getNpmVersion();
expect(version).toBe('unknown');
});
});
describe('getSandboxEnv', () => {
it('should return "no sandbox" when SANDBOX is not set', () => {
delete process.env['SANDBOX'];
expect(getSandboxEnv()).toBe('no sandbox');
});
it('should return sandbox-exec info when SANDBOX is sandbox-exec', () => {
process.env['SANDBOX'] = 'sandbox-exec';
process.env['SEATBELT_PROFILE'] = 'test-profile';
expect(getSandboxEnv()).toBe('sandbox-exec (test-profile)');
});
it('should return sandbox name without prefix when stripPrefix is true', () => {
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
expect(getSandboxEnv(true)).toBe('test-sandbox');
});
it('should return sandbox name with prefix when stripPrefix is false', () => {
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
expect(getSandboxEnv(false)).toBe('qwen-code-test-sandbox');
});
it('should handle qwen- prefix removal', () => {
process.env['SANDBOX'] = 'qwen-custom-sandbox';
expect(getSandboxEnv(true)).toBe('custom-sandbox');
});
});
describe('getIdeClientName', () => {
it('should return IDE client name when IDE mode is enabled', async () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
} as unknown as IdeClient);
const ideClient = await getIdeClientName(mockContext);
expect(ideClient).toBe('test-ide');
});
it('should return empty string when IDE mode is disabled', async () => {
vi.mocked(mockContext.services.config!.getIdeMode).mockReturnValue(false);
const ideClient = await getIdeClientName(mockContext);
expect(ideClient).toBe('');
});
it('should return empty string when IDE client detection fails', async () => {
vi.mocked(IdeClient.getInstance).mockRejectedValue(
new Error('IDE client error'),
);
const ideClient = await getIdeClientName(mockContext);
expect(ideClient).toBe('');
});
});
describe('getSystemInfo', () => {
it('should collect all system information', async () => {
// Ensure SANDBOX is not set for this test
delete process.env['SANDBOX'];
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
} as unknown as IdeClient);
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
const systemInfo = await getSystemInfo(mockContext);
expect(systemInfo).toEqual({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
});
});
it('should handle missing config gracefully', async () => {
mockContext.services.config = null;
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
} as unknown as IdeClient);
const systemInfo = await getSystemInfo(mockContext);
expect(systemInfo.modelVersion).toBe('Unknown');
expect(systemInfo.sessionId).toBe('unknown');
});
});
describe('getExtendedSystemInfo', () => {
it('should include memory usage and base URL', async () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
} as unknown as IdeClient);
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
const { AuthType } = await import('@qwen-code/qwen-code-core');
// Update the mock context to use OpenAI auth
mockContext.services.settings.merged.security!.auth!.selectedType =
AuthType.USE_OPENAI;
const extendedInfo = await getExtendedSystemInfo(mockContext);
expect(extendedInfo.memoryUsage).toBeDefined();
expect(extendedInfo.memoryUsage).toMatch(/\d+\.\d+ (KB|MB|GB)/);
expect(extendedInfo.baseUrl).toBe('https://api.openai.com');
});
it('should use sandbox env without prefix for bug reports', async () => {
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
} as unknown as IdeClient);
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
const extendedInfo = await getExtendedSystemInfo(mockContext);
expect(extendedInfo.sandboxEnv).toBe('test-sandbox');
});
it('should not include base URL for non-OpenAI auth', async () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
} as unknown as IdeClient);
vi.mocked(child_process.execSync).mockImplementation(
(command: string, options?: ExecSyncOptions) => {
if (
options &&
typeof options === 'object' &&
'encoding' in options &&
options.encoding === 'utf-8'
) {
return '10.0.0';
}
return Buffer.from('10.0.0', 'utf-8');
},
);
const extendedInfo = await getExtendedSystemInfo(mockContext);
expect(extendedInfo.baseUrl).toBeUndefined();
});
});
});

View File

@@ -1,173 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
import os from 'node:os';
import { execSync } from 'node:child_process';
import type { CommandContext } from '../ui/commands/types.js';
import { getCliVersion } from './version.js';
import { IdeClient, AuthType } from '@qwen-code/qwen-code-core';
import { formatMemoryUsage } from '../ui/utils/formatters.js';
import { GIT_COMMIT_INFO } from '../generated/git-commit.js';
/**
* System information interface containing all system-related details
* that can be collected for debugging and reporting purposes.
*/
export interface SystemInfo {
cliVersion: string;
osPlatform: string;
osArch: string;
osRelease: string;
nodeVersion: string;
npmVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
ideClient: string;
sessionId: string;
}
/**
* Additional system information for bug reports
*/
export interface ExtendedSystemInfo extends SystemInfo {
memoryUsage: string;
baseUrl?: string;
gitCommit?: string;
}
/**
* Gets the NPM version, handling cases where npm might not be available.
* Returns 'unknown' if npm command fails or is not found.
*/
export async function getNpmVersion(): Promise<string> {
try {
return execSync('npm --version', { encoding: 'utf-8' }).trim();
} catch {
return 'unknown';
}
}
/**
* Gets the IDE client name if IDE mode is enabled.
* Returns empty string if IDE mode is disabled or IDE client is not detected.
*/
export async function getIdeClientName(
context: CommandContext,
): Promise<string> {
if (!context.services.config?.getIdeMode()) {
return '';
}
try {
const ideClient = await IdeClient.getInstance();
return ideClient?.getDetectedIdeDisplayName() ?? '';
} catch {
return '';
}
}
/**
* Gets the sandbox environment information.
* Handles different sandbox types including sandbox-exec and custom sandbox environments.
* For bug reports, removes 'qwen-' or 'qwen-code-' prefixes from sandbox names.
*
* @param stripPrefix - Whether to strip 'qwen-' prefix (used for bug reports)
*/
export function getSandboxEnv(stripPrefix = false): string {
const sandbox = process.env['SANDBOX'];
if (!sandbox || sandbox === 'sandbox-exec') {
if (sandbox === 'sandbox-exec') {
const profile = process.env['SEATBELT_PROFILE'] || 'unknown';
return `sandbox-exec (${profile})`;
}
return 'no sandbox';
}
// For bug reports, remove qwen- prefix
if (stripPrefix) {
return sandbox.replace(/^qwen-(?:code-)?/, '');
}
return sandbox;
}
/**
* Collects comprehensive system information for debugging and reporting.
* This function gathers all system-related details including OS, versions,
* sandbox environment, authentication, and session information.
*
* @param context - Command context containing config and settings
* @returns Promise resolving to SystemInfo object with all collected information
*/
export async function getSystemInfo(
context: CommandContext,
): Promise<SystemInfo> {
const osPlatform = process.platform;
const osArch = process.arch;
const osRelease = os.release();
const nodeVersion = process.version;
const npmVersion = await getNpmVersion();
const sandboxEnv = getSandboxEnv();
const modelVersion = context.services.config?.getModel() || 'Unknown';
const cliVersion = await getCliVersion();
const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
const ideClient = await getIdeClientName(context);
const sessionId = context.services.config?.getSessionId() || 'unknown';
return {
cliVersion,
osPlatform,
osArch,
osRelease,
nodeVersion,
npmVersion,
sandboxEnv,
modelVersion,
selectedAuthType,
ideClient,
sessionId,
};
}
/**
* Collects extended system information for bug reports.
* Includes all standard system info plus memory usage and optional base URL.
*
* @param context - Command context containing config and settings
* @returns Promise resolving to ExtendedSystemInfo object
*/
export async function getExtendedSystemInfo(
context: CommandContext,
): Promise<ExtendedSystemInfo> {
const baseInfo = await getSystemInfo(context);
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
// For bug reports, use sandbox name without prefix
const sandboxEnv = getSandboxEnv(true);
// Get base URL if using OpenAI auth
const baseUrl =
baseInfo.selectedAuthType === AuthType.USE_OPENAI
? context.services.config?.getContentGeneratorConfig()?.baseUrl
: undefined;
// Get git commit info
const gitCommit =
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? GIT_COMMIT_INFO
: undefined;
return {
...baseInfo,
sandboxEnv,
memoryUsage,
baseUrl,
gitCommit,
};
}

View File

@@ -1,117 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { ExtendedSystemInfo } from './systemInfo.js';
/**
* Field configuration for system information display
*/
export interface SystemInfoField {
label: string;
key: keyof ExtendedSystemInfo;
}
/**
* Unified field configuration for system information display.
* This ensures consistent labeling between /about and /bug commands.
*/
export function getSystemInfoFields(
info: ExtendedSystemInfo,
): SystemInfoField[] {
const allFields: SystemInfoField[] = [
{
label: 'CLI Version',
key: 'cliVersion',
},
{
label: 'Git Commit',
key: 'gitCommit',
},
{
label: 'Model',
key: 'modelVersion',
},
{
label: 'Sandbox',
key: 'sandboxEnv',
},
{
label: 'OS Platform',
key: 'osPlatform',
},
{
label: 'OS Arch',
key: 'osArch',
},
{
label: 'OS Release',
key: 'osRelease',
},
{
label: 'Node.js Version',
key: 'nodeVersion',
},
{
label: 'NPM Version',
key: 'npmVersion',
},
{
label: 'Session ID',
key: 'sessionId',
},
{
label: 'Auth Method',
key: 'selectedAuthType',
},
{
label: 'Base URL',
key: 'baseUrl',
},
{
label: 'Memory Usage',
key: 'memoryUsage',
},
{
label: 'IDE Client',
key: 'ideClient',
},
];
// Filter out optional fields that are not present
return allFields.filter((field) => {
const value = info[field.key];
// Optional fields: only show if they exist and are non-empty
if (
field.key === 'baseUrl' ||
field.key === 'gitCommit' ||
field.key === 'ideClient'
) {
return Boolean(value);
}
return true;
});
}
/**
* Get the value for a field from system info
*/
export function getFieldValue(
field: SystemInfoField,
info: ExtendedSystemInfo,
): string {
const value = info[field.key];
if (value === undefined || value === null) {
return '';
}
// Special formatting for selectedAuthType
if (field.key === 'selectedAuthType') {
return String(value).startsWith('oauth') ? 'OAuth' : String(value);
}
return String(value);
}

View File

@@ -22,22 +22,12 @@ vi.mock('os', async (importOriginal) => {
describe('getUserStartupWarnings', () => {
let testRootDir: string;
let homeDir: string;
let startupOptions: {
workspaceRoot: string;
useRipgrep: boolean;
useBuiltinRipgrep: boolean;
};
beforeEach(async () => {
testRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'warnings-test-'));
homeDir = path.join(testRootDir, 'home');
await fs.mkdir(homeDir, { recursive: true });
vi.mocked(os.homedir).mockReturnValue(homeDir);
startupOptions = {
workspaceRoot: testRootDir,
useRipgrep: true,
useBuiltinRipgrep: true,
};
});
afterEach(async () => {
@@ -47,10 +37,7 @@ describe('getUserStartupWarnings', () => {
describe('home directory check', () => {
it('should return a warning when running in home directory', async () => {
const warnings = await getUserStartupWarnings({
...startupOptions,
workspaceRoot: homeDir,
});
const warnings = await getUserStartupWarnings(homeDir);
expect(warnings).toContainEqual(
expect.stringContaining('home directory'),
);
@@ -59,10 +46,7 @@ describe('getUserStartupWarnings', () => {
it('should not return a warning when running in a project directory', async () => {
const projectDir = path.join(testRootDir, 'project');
await fs.mkdir(projectDir);
const warnings = await getUserStartupWarnings({
...startupOptions,
workspaceRoot: projectDir,
});
const warnings = await getUserStartupWarnings(projectDir);
expect(warnings).not.toContainEqual(
expect.stringContaining('home directory'),
);
@@ -72,10 +56,7 @@ describe('getUserStartupWarnings', () => {
describe('root directory check', () => {
it('should return a warning when running in a root directory', async () => {
const rootDir = path.parse(testRootDir).root;
const warnings = await getUserStartupWarnings({
...startupOptions,
workspaceRoot: rootDir,
});
const warnings = await getUserStartupWarnings(rootDir);
expect(warnings).toContainEqual(
expect.stringContaining('root directory'),
);
@@ -87,10 +68,7 @@ describe('getUserStartupWarnings', () => {
it('should not return a warning when running in a non-root directory', async () => {
const projectDir = path.join(testRootDir, 'project');
await fs.mkdir(projectDir);
const warnings = await getUserStartupWarnings({
...startupOptions,
workspaceRoot: projectDir,
});
const warnings = await getUserStartupWarnings(projectDir);
expect(warnings).not.toContainEqual(
expect.stringContaining('root directory'),
);
@@ -100,10 +78,7 @@ describe('getUserStartupWarnings', () => {
describe('error handling', () => {
it('should handle errors when checking directory', async () => {
const nonExistentPath = path.join(testRootDir, 'non-existent');
const warnings = await getUserStartupWarnings({
...startupOptions,
workspaceRoot: nonExistentPath,
});
const warnings = await getUserStartupWarnings(nonExistentPath);
const expectedWarning =
'Could not verify the current directory due to a file system error.';
expect(warnings).toEqual([expectedWarning, expectedWarning]);

View File

@@ -7,26 +7,19 @@
import fs from 'node:fs/promises';
import * as os from 'node:os';
import path from 'node:path';
import { canUseRipgrep } from '@qwen-code/qwen-code-core';
type WarningCheckOptions = {
workspaceRoot: string;
useRipgrep: boolean;
useBuiltinRipgrep: boolean;
};
type WarningCheck = {
id: string;
check: (options: WarningCheckOptions) => Promise<string | null>;
check: (workspaceRoot: string) => Promise<string | null>;
};
// Individual warning checks
const homeDirectoryCheck: WarningCheck = {
id: 'home-directory',
check: async (options: WarningCheckOptions) => {
check: async (workspaceRoot: string) => {
try {
const [workspaceRealPath, homeRealPath] = await Promise.all([
fs.realpath(options.workspaceRoot),
fs.realpath(workspaceRoot),
fs.realpath(os.homedir()),
]);
@@ -42,9 +35,9 @@ const homeDirectoryCheck: WarningCheck = {
const rootDirectoryCheck: WarningCheck = {
id: 'root-directory',
check: async (options: WarningCheckOptions) => {
check: async (workspaceRoot: string) => {
try {
const workspaceRealPath = await fs.realpath(options.workspaceRoot);
const workspaceRealPath = await fs.realpath(workspaceRoot);
const errorMessage =
'Warning: You are running Qwen Code in the root directory. Your entire folder structure will be used for context. It is strongly recommended to run in a project-specific directory.';
@@ -60,33 +53,17 @@ const rootDirectoryCheck: WarningCheck = {
},
};
const ripgrepAvailabilityCheck: WarningCheck = {
id: 'ripgrep-availability',
check: async (options: WarningCheckOptions) => {
if (!options.useRipgrep) {
return null;
}
const isAvailable = await canUseRipgrep(options.useBuiltinRipgrep);
if (!isAvailable) {
return 'Ripgrep not available: Please install ripgrep globally to enable faster file content search. Falling back to built-in grep.';
}
return null;
},
};
// All warning checks
const WARNING_CHECKS: readonly WarningCheck[] = [
homeDirectoryCheck,
rootDirectoryCheck,
ripgrepAvailabilityCheck,
];
export async function getUserStartupWarnings(
options: WarningCheckOptions,
workspaceRoot: string = process.cwd(),
): Promise<string[]> {
const results = await Promise.all(
WARNING_CHECKS.map((check) => check.check(options)),
WARNING_CHECKS.map((check) => check.check(workspaceRoot)),
);
return results.filter((msg) => msg !== null);
}

View File

@@ -105,6 +105,34 @@ describe('validateNonInterActiveAuth', () => {
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it('uses LOGIN_WITH_GOOGLE if GOOGLE_GENAI_USE_GCA is set', async () => {
process.env['GOOGLE_GENAI_USE_GCA'] = 'true';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.LOGIN_WITH_GOOGLE);
});
it('uses USE_GEMINI if GEMINI_API_KEY is set', async () => {
process.env['GEMINI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI);
});
it('uses USE_OPENAI if OPENAI_API_KEY is set', async () => {
process.env['OPENAI_API_KEY'] = 'fake-openai-key';
const nonInteractiveConfig = {
@@ -140,6 +168,104 @@ describe('validateNonInterActiveAuth', () => {
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.QWEN_OAUTH);
});
it('uses USE_VERTEX_AI if GOOGLE_GENAI_USE_VERTEXAI is true (with GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION)', async () => {
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project';
process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_VERTEX_AI);
});
it('uses USE_VERTEX_AI if GOOGLE_GENAI_USE_VERTEXAI is true and GOOGLE_API_KEY is set', async () => {
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
process.env['GOOGLE_API_KEY'] = 'vertex-api-key';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_VERTEX_AI);
});
it('uses LOGIN_WITH_GOOGLE if GOOGLE_GENAI_USE_GCA is set, even with other env vars', async () => {
process.env['GOOGLE_GENAI_USE_GCA'] = 'true';
process.env['GEMINI_API_KEY'] = 'fake-key';
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project';
process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.LOGIN_WITH_GOOGLE);
});
it('uses USE_VERTEX_AI if both GEMINI_API_KEY and GOOGLE_GENAI_USE_VERTEXAI are set', async () => {
process.env['GEMINI_API_KEY'] = 'fake-key';
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project';
process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_VERTEX_AI);
});
it('uses USE_GEMINI if GOOGLE_GENAI_USE_VERTEXAI is false, GEMINI_API_KEY is set, and project/location are available', async () => {
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'false';
process.env['GEMINI_API_KEY'] = 'fake-key';
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project';
process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI);
});
it('uses configuredAuthType if provided', async () => {
// Set required env var for USE_GEMINI
process.env['GEMINI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
AuthType.USE_GEMINI,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI);
});
it('exits if validateAuthMethod returns error', async () => {
// Mock validateAuthMethod to return error
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
@@ -191,25 +317,26 @@ describe('validateNonInterActiveAuth', () => {
});
it('uses enforcedAuthType if provided', async () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.USE_OPENAI;
mockSettings.merged.security!.auth!.selectedType = AuthType.USE_OPENAI;
// Set required env var for USE_OPENAI to ensure enforcedAuthType takes precedence
process.env['OPENAI_API_KEY'] = 'fake-key';
mockSettings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
mockSettings.merged.security!.auth!.selectedType = AuthType.USE_GEMINI;
// Set required env var for USE_GEMINI to ensure enforcedAuthType takes precedence
process.env['GEMINI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
AuthType.USE_OPENAI,
AuthType.USE_GEMINI,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_OPENAI);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI);
});
it('exits if currentAuthType does not match enforcedAuthType', async () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
process.env['OPENAI_API_KEY'] = 'fake-key';
mockSettings.merged.security!.auth!.enforcedType =
AuthType.LOGIN_WITH_GOOGLE;
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
@@ -219,7 +346,7 @@ describe('validateNonInterActiveAuth', () => {
} as unknown as Config;
try {
await validateNonInteractiveAuth(
AuthType.USE_OPENAI,
AuthType.USE_GEMINI,
undefined,
nonInteractiveConfig,
mockSettings,
@@ -229,7 +356,7 @@ describe('validateNonInterActiveAuth', () => {
expect((e as Error).message).toContain('process.exit(1) called');
}
expect(consoleErrorSpy).toHaveBeenCalledWith(
'The configured auth type is qwen-oauth, but the current auth type is openai. Please re-authenticate with the correct type.',
'The configured auth type is oauth-personal, but the current auth type is vertex-ai. Please re-authenticate with the correct type.',
);
expect(processExitSpy).toHaveBeenCalledWith(1);
});
@@ -267,8 +394,8 @@ describe('validateNonInterActiveAuth', () => {
});
it('prints JSON error when enforced auth mismatches current auth and exits with code 1', async () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
process.env['OPENAI_API_KEY'] = 'fake-key';
mockSettings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
process.env['GOOGLE_GENAI_USE_GCA'] = 'true';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
@@ -297,14 +424,14 @@ describe('validateNonInterActiveAuth', () => {
expect(payload.error.type).toBe('Error');
expect(payload.error.code).toBe(1);
expect(payload.error.message).toContain(
'The configured auth type is qwen-oauth, but the current auth type is openai.',
'The configured auth type is gemini-api-key, but the current auth type is oauth-personal.',
);
}
});
it('prints JSON error when validateAuthMethod fails and exits with code 1', async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
process.env['OPENAI_API_KEY'] = 'fake-key';
process.env['GEMINI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
@@ -317,7 +444,7 @@ describe('validateNonInterActiveAuth', () => {
let thrown: Error | undefined;
try {
await validateNonInteractiveAuth(
AuthType.USE_OPENAI,
AuthType.USE_GEMINI,
undefined,
nonInteractiveConfig,
mockSettings,

View File

@@ -12,13 +12,18 @@ import { type LoadedSettings } from './config/settings.js';
import { handleError } from './utils/errors.js';
function getAuthTypeFromEnv(): AuthType | undefined {
if (process.env['GOOGLE_GENAI_USE_GCA'] === 'true') {
return AuthType.LOGIN_WITH_GOOGLE;
}
if (process.env['GOOGLE_GENAI_USE_VERTEXAI'] === 'true') {
return AuthType.USE_VERTEX_AI;
}
if (process.env['GEMINI_API_KEY']) {
return AuthType.USE_GEMINI;
}
if (process.env['OPENAI_API_KEY']) {
return AuthType.USE_OPENAI;
}
if (process.env['QWEN_OAUTH']) {
return AuthType.QWEN_OAUTH;
}
return undefined;
}
@@ -42,7 +47,7 @@ export async function validateNonInteractiveAuth(
enforcedType || getAuthTypeFromEnv() || configuredAuthType;
if (!effectiveAuthType) {
const message = `Please set an Auth method in your ${USER_SETTINGS_PATH} or specify one of the following environment variables before running: QWEN_OAUTH, OPENAI_API_KEY`;
const message = `Please set an Auth method in your ${USER_SETTINGS_PATH} or specify one of the following environment variables before running: GEMINI_API_KEY, OPENAI_API_KEY, GOOGLE_GENAI_USE_VERTEXAI, GOOGLE_GENAI_USE_GCA`;
throw new Error(message);
}

View File

@@ -7,6 +7,7 @@
/* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */
import { z } from 'zod';
import { EOL } from 'node:os';
import * as schema from './schema.js';
export * from './schema.js';
@@ -172,7 +173,7 @@ class Connection {
const decoder = new TextDecoder();
for await (const chunk of output) {
content += decoder.decode(chunk, { stream: true });
const lines = content.split('\n');
const lines = content.split(EOL);
content = lines.pop() || '';
for (const line of lines) {

View File

@@ -128,14 +128,6 @@ export type AgentRequest = z.infer<typeof agentRequestSchema>;
export type AgentNotification = z.infer<typeof agentNotificationSchema>;
export type AvailableCommandInput = z.infer<typeof availableCommandInputSchema>;
export type AvailableCommand = z.infer<typeof availableCommandSchema>;
export type AvailableCommandsUpdate = z.infer<
typeof availableCommandsUpdateSchema
>;
export const writeTextFileRequestSchema = z.object({
content: z.string(),
path: z.string(),
@@ -394,21 +386,6 @@ export const promptRequestSchema = z.object({
sessionId: z.string(),
});
export const availableCommandInputSchema = z.object({
hint: z.string(),
});
export const availableCommandSchema = z.object({
description: z.string(),
input: availableCommandInputSchema.nullable().optional(),
name: z.string(),
});
export const availableCommandsUpdateSchema = z.object({
availableCommands: z.array(availableCommandSchema),
sessionUpdate: z.literal('available_commands_update'),
});
export const sessionUpdateSchema = z.union([
z.object({
content: contentBlockSchema,
@@ -446,7 +423,6 @@ export const sessionUpdateSchema = z.union([
entries: z.array(planEntrySchema),
sessionUpdate: z.literal('plan'),
}),
availableCommandsUpdateSchema,
]);
export const agentResponseSchema = z.union([

View File

@@ -12,12 +12,6 @@ import type {
GeminiChat,
ToolCallConfirmationDetails,
ToolResult,
SubAgentEventEmitter,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
AnyDeclarativeTool,
AnyToolInvocation,
} from '@qwen-code/qwen-code-core';
import {
AuthType,
@@ -31,15 +25,9 @@ import {
MCPServerConfig,
ToolConfirmationOutcome,
logToolCall,
logUserPrompt,
getErrorStatus,
isWithinRoot,
isNodeError,
SubAgentEventType,
TaskTool,
Kind,
TodoWriteTool,
UserPromptEvent,
} from '@qwen-code/qwen-code-core';
import * as acp from './acp.js';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -55,26 +43,6 @@ import { ExtensionStorage, type Extension } from '../config/extension.js';
import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js';
import {
handleSlashCommand,
getAvailableCommands,
} from '../nonInteractiveCliCommands.js';
import type { AvailableCommand, AvailableCommandsUpdate } from './schema.js';
import { isSlashCommand } from '../ui/utils/commandUtils.js';
/**
* Built-in commands that are allowed in ACP integration mode.
* Only these commands will be available when using handleSlashCommand
* or getAvailableCommands in ACP integration.
*
* Currently, only "init" is supported because `handleSlashCommand` in
* nonInteractiveCliCommands.ts only supports handling results where
* result.type is "submit_prompt". Other result types are either coupled
* to the UI or cannot send notifications to the client via ACP.
*
* If you have a good idea to add support for more commands, PRs are welcome!
*/
const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init'];
/**
* Resolves the model to use based on the current configuration.
@@ -173,7 +141,7 @@ class GeminiAgent {
cwd,
mcpServers,
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
const sessionId = this.config.getSessionId() || randomUUID();
const sessionId = randomUUID();
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
let isAuthenticated = false;
@@ -204,20 +172,9 @@ class GeminiAgent {
const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat();
const session = new Session(
sessionId,
chat,
config,
this.client,
this.settings,
);
const session = new Session(sessionId, chat, config, this.client);
this.sessions.set(sessionId, session);
// Send available commands update as the first session update
setTimeout(async () => {
await session.sendAvailableCommandsUpdate();
}, 0);
return {
sessionId,
};
@@ -275,14 +232,12 @@ class GeminiAgent {
class Session {
private pendingPrompt: AbortController | null = null;
private turn: number = 0;
constructor(
private readonly id: string,
private readonly chat: GeminiChat,
private readonly config: Config,
private readonly client: acp.Client,
private readonly settings: LoadedSettings,
) {}
async cancelPendingPrompt(): Promise<void> {
@@ -299,57 +254,10 @@ class Session {
const pendingSend = new AbortController();
this.pendingPrompt = pendingSend;
// Increment turn counter for each user prompt
this.turn += 1;
const promptId = Math.random().toString(16).slice(2);
const chat = this.chat;
const promptId = this.config.getSessionId() + '########' + this.turn;
// Extract text from all text blocks to construct the full prompt text for logging
const promptText = params.prompt
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join(' ');
// Log user prompt
logUserPrompt(
this.config,
new UserPromptEvent(
promptText.length,
promptId,
this.config.getContentGeneratorConfig()?.authType,
promptText,
),
);
// Check if the input contains a slash command
// Extract text from the first text block if present
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
const inputText = firstTextBlock?.text || '';
let parts: Part[];
if (isSlashCommand(inputText)) {
// Handle slash command - allow specific built-in commands for ACP integration
const slashCommandResult = await handleSlashCommand(
inputText,
pendingSend,
this.config,
this.settings,
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
);
if (slashCommandResult) {
// Use the result from the slash command
parts = slashCommandResult as Part[];
} else {
// Slash command didn't return a prompt, continue with normal processing
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
}
} else {
// Normal processing for non-slash commands
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
}
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
let nextMessage: Content | null = { role: 'user', parts };
@@ -443,37 +351,6 @@ class Session {
await this.client.sessionUpdate(params);
}
async sendAvailableCommandsUpdate(): Promise<void> {
const abortController = new AbortController();
try {
const slashCommands = await getAvailableCommands(
this.config,
this.settings,
abortController.signal,
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
);
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
const availableCommands: AvailableCommand[] = slashCommands.map(
(cmd) => ({
name: cmd.name,
description: cmd.description,
input: null,
}),
);
const update: AvailableCommandsUpdate = {
sessionUpdate: 'available_commands_update',
availableCommands,
};
await this.sendUpdate(update);
} catch (error) {
// Log error but don't fail session creation
console.error('Error sending available commands update:', error);
}
}
private async runTool(
abortSignal: AbortSignal,
promptId: string,
@@ -526,34 +403,9 @@ class Session {
);
}
// Detect TodoWriteTool early - route to plan updates instead of tool_call events
const isTodoWriteTool =
fc.name === TodoWriteTool.Name || tool.name === TodoWriteTool.Name;
// Declare subAgentToolEventListeners outside try block for cleanup in catch
let subAgentToolEventListeners: Array<() => void> = [];
try {
const invocation = tool.build(args);
// Detect TaskTool and set up sub-agent tool tracking
const isTaskTool = tool.name === TaskTool.Name;
if (isTaskTool && 'eventEmitter' in invocation) {
// Access eventEmitter from TaskTool invocation
const taskEventEmitter = (
invocation as {
eventEmitter: SubAgentEventEmitter;
}
).eventEmitter;
// Set up sub-agent tool tracking
subAgentToolEventListeners = this.setupSubAgentToolTracking(
taskEventEmitter,
abortSignal,
);
}
const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal);
@@ -608,8 +460,7 @@ class Session {
throw new Error(`Unexpected: ${resultOutcome}`);
}
}
} else if (!isTodoWriteTool) {
// Skip tool_call event for TodoWriteTool
} else {
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: callId,
@@ -622,61 +473,14 @@ class Session {
}
const toolResult: ToolResult = await invocation.execute(abortSignal);
const content = toToolCallContent(toolResult);
// Clean up event listeners
subAgentToolEventListeners.forEach((cleanup) => cleanup());
// Handle TodoWriteTool: extract todos and send plan update
if (isTodoWriteTool) {
// Extract todos from args (initial state)
let todos: Array<{
id: string;
content: string;
status: 'pending' | 'in_progress' | 'completed';
}> = [];
if (Array.isArray(args['todos'])) {
todos = args['todos'] as Array<{
id: string;
content: string;
status: 'pending' | 'in_progress' | 'completed';
}>;
}
// If returnDisplay has todos (e.g., modified by user), use those instead
if (
toolResult.returnDisplay &&
typeof toolResult.returnDisplay === 'object' &&
'type' in toolResult.returnDisplay &&
toolResult.returnDisplay.type === 'todo_list' &&
'todos' in toolResult.returnDisplay &&
Array.isArray(toolResult.returnDisplay.todos)
) {
todos = toolResult.returnDisplay.todos;
}
// Convert todos to plan entries and send plan update
if (todos.length > 0 || Array.isArray(args['todos'])) {
const planEntries = convertTodosToPlanEntries(todos);
await this.sendUpdate({
sessionUpdate: 'plan',
entries: planEntries,
});
}
// Skip tool_call_update event for TodoWriteTool
// Still log and return function response for LLM
} else {
// Normal tool handling: send tool_call_update
const content = toToolCallContent(toolResult);
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'completed',
content: content ? [content] : [],
});
}
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'completed',
content: content ? [content] : [],
});
const durationMs = Date.now() - startTime;
logToolCall(this.config, {
@@ -696,9 +500,6 @@ class Session {
return convertToFunctionResponse(fc.name, callId, toolResult.llmContent);
} catch (e) {
// Ensure cleanup on error
subAgentToolEventListeners.forEach((cleanup) => cleanup());
const error = e instanceof Error ? e : new Error(String(e));
await this.sendUpdate({
@@ -714,300 +515,6 @@ class Session {
}
}
/**
* Sets up event listeners to track sub-agent tool calls within a TaskTool execution.
* Converts subagent tool call events into zedIntegration session updates.
*
* @param eventEmitter - The SubAgentEventEmitter from TaskTool
* @param abortSignal - Signal to abort tracking if parent is cancelled
* @returns Array of cleanup functions to remove event listeners
*/
private setupSubAgentToolTracking(
eventEmitter: SubAgentEventEmitter,
abortSignal: AbortSignal,
): Array<() => void> {
const cleanupFunctions: Array<() => void> = [];
const toolRegistry = this.config.getToolRegistry();
// Track subagent tool call states
const subAgentToolStates = new Map<
string,
{
tool?: AnyDeclarativeTool;
invocation?: AnyToolInvocation;
args?: Record<string, unknown>;
}
>();
// Listen for tool call start
const onToolCall = (...args: unknown[]) => {
const event = args[0] as SubAgentToolCallEvent;
if (abortSignal.aborted) return;
const subAgentTool = toolRegistry.getTool(event.name);
let subAgentInvocation: AnyToolInvocation | undefined;
let toolKind: acp.ToolKind = 'other';
let locations: acp.ToolCallLocation[] = [];
if (subAgentTool) {
try {
subAgentInvocation = subAgentTool.build(event.args);
toolKind = this.mapToolKind(subAgentTool.kind);
locations = subAgentInvocation.toolLocations().map((loc) => ({
path: loc.path,
line: loc.line ?? null,
}));
} catch (e) {
// If building fails, continue with defaults
console.warn(`Failed to build subagent tool ${event.name}:`, e);
}
}
// Save state for subsequent updates
subAgentToolStates.set(event.callId, {
tool: subAgentTool,
invocation: subAgentInvocation,
args: event.args,
});
// Check if this is TodoWriteTool - if so, skip sending tool_call event
// Plan update will be sent in onToolResult when we have the final state
if (event.name === TodoWriteTool.Name) {
return;
}
// Send tool call start update with rawInput
void this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: event.callId,
status: 'in_progress',
title: event.description || event.name,
content: [],
locations,
kind: toolKind,
rawInput: event.args,
});
};
// Listen for tool call result
const onToolResult = (...args: unknown[]) => {
const event = args[0] as SubAgentToolResultEvent;
if (abortSignal.aborted) return;
const state = subAgentToolStates.get(event.callId);
// Check if this is TodoWriteTool - if so, route to plan updates
if (event.name === TodoWriteTool.Name) {
let todos:
| Array<{
id: string;
content: string;
status: 'pending' | 'in_progress' | 'completed';
}>
| undefined;
// Try to extract todos from resultDisplay first (final state)
if (event.resultDisplay) {
try {
// resultDisplay might be a JSON stringified object
const parsed =
typeof event.resultDisplay === 'string'
? JSON.parse(event.resultDisplay)
: event.resultDisplay;
if (
typeof parsed === 'object' &&
parsed !== null &&
'type' in parsed &&
parsed.type === 'todo_list' &&
'todos' in parsed &&
Array.isArray(parsed.todos)
) {
todos = parsed.todos;
}
} catch {
// If parsing fails, ignore - resultDisplay might not be JSON
}
}
// Fallback to args if resultDisplay doesn't have todos
if (!todos && state?.args && Array.isArray(state.args['todos'])) {
todos = state.args['todos'] as Array<{
id: string;
content: string;
status: 'pending' | 'in_progress' | 'completed';
}>;
}
// Send plan update if we have todos
if (todos) {
const planEntries = convertTodosToPlanEntries(todos);
void this.sendUpdate({
sessionUpdate: 'plan',
entries: planEntries,
});
}
// Skip sending tool_call_update event for TodoWriteTool
// Clean up state
subAgentToolStates.delete(event.callId);
return;
}
let content: acp.ToolCallContent[] = [];
// If there's a result display, try to convert to ToolCallContent
if (event.resultDisplay && state?.invocation) {
// resultDisplay is typically a string
if (typeof event.resultDisplay === 'string') {
content = [
{
type: 'content',
content: {
type: 'text',
text: event.resultDisplay,
},
},
];
}
}
// Send tool call completion update
void this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: event.callId,
status: event.success ? 'completed' : 'failed',
content: content.length > 0 ? content : [],
title: state?.invocation?.getDescription() ?? event.name,
kind: state?.tool ? this.mapToolKind(state.tool.kind) : null,
locations:
state?.invocation?.toolLocations().map((loc) => ({
path: loc.path,
line: loc.line ?? null,
})) ?? null,
rawInput: state?.args,
});
// Clean up state
subAgentToolStates.delete(event.callId);
};
// Listen for permission requests
const onToolWaitingApproval = async (...args: unknown[]) => {
const event = args[0] as SubAgentApprovalRequestEvent;
if (abortSignal.aborted) return;
const state = subAgentToolStates.get(event.callId);
const content: acp.ToolCallContent[] = [];
// Handle different confirmation types
if (event.confirmationDetails.type === 'edit') {
const editDetails = event.confirmationDetails as unknown as {
type: 'edit';
fileName: string;
originalContent: string | null;
newContent: string;
};
content.push({
type: 'diff',
path: editDetails.fileName,
oldText: editDetails.originalContent ?? '',
newText: editDetails.newContent,
});
}
// Build permission request options from confirmation details
// event.confirmationDetails already contains all fields except onConfirm,
// which we add here to satisfy the type requirement for toPermissionOptions
const fullConfirmationDetails = {
...event.confirmationDetails,
onConfirm: async () => {
// This is a placeholder - the actual response is handled via event.respond
},
} as unknown as ToolCallConfirmationDetails;
const params: acp.RequestPermissionRequest = {
sessionId: this.id,
options: toPermissionOptions(fullConfirmationDetails),
toolCall: {
toolCallId: event.callId,
status: 'pending',
title: event.description || event.name,
content,
locations:
state?.invocation?.toolLocations().map((loc) => ({
path: loc.path,
line: loc.line ?? null,
})) ?? [],
kind: state?.tool ? this.mapToolKind(state.tool.kind) : 'other',
rawInput: state?.args,
},
};
try {
// Request permission from zed client
const output = await this.client.requestPermission(params);
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
// Respond to subagent with the outcome
await event.respond(outcome);
} catch (error) {
// If permission request fails, cancel the tool call
console.error(
`Permission request failed for subagent tool ${event.name}:`,
error,
);
await event.respond(ToolConfirmationOutcome.Cancel);
}
};
// Register event listeners
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(
SubAgentEventType.TOOL_WAITING_APPROVAL,
onToolWaitingApproval,
);
// Return cleanup functions
cleanupFunctions.push(() => {
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(
SubAgentEventType.TOOL_WAITING_APPROVAL,
onToolWaitingApproval,
);
});
return cleanupFunctions;
}
/**
* Maps core Tool Kind enum to ACP ToolKind string literals.
*
* @param kind - The core Kind enum value
* @returns The corresponding ACP ToolKind string literal
*/
private mapToolKind(kind: Kind): acp.ToolKind {
const kindMap: Record<Kind, acp.ToolKind> = {
[Kind.Read]: 'read',
[Kind.Edit]: 'edit',
[Kind.Delete]: 'delete',
[Kind.Move]: 'move',
[Kind.Search]: 'search',
[Kind.Execute]: 'execute',
[Kind.Think]: 'think',
[Kind.Fetch]: 'fetch',
[Kind.Other]: 'other',
};
return kindMap[kind] ?? 'other';
}
async #resolvePrompt(
message: acp.ContentBlock[],
abortSignal: AbortSignal,
@@ -1352,27 +859,6 @@ class Session {
}
}
/**
* Converts todo items to plan entries format for zed integration.
* Maps todo status to plan status and assigns a default priority.
*
* @param todos - Array of todo items with id, content, and status
* @returns Array of plan entries with content, priority, and status
*/
function convertTodosToPlanEntries(
todos: Array<{
id: string;
content: string;
status: 'pending' | 'in_progress' | 'completed';
}>,
): acp.PlanEntry[] {
return todos.map((todo) => ({
content: todo.content,
priority: 'medium' as const, // Default priority since todos don't have priority
status: todo.status,
}));
}
function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
if (toolResult.error?.message) {
throw new Error(toolResult.error.message);
@@ -1384,6 +870,26 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
type: 'content',
content: { type: 'text', text: toolResult.returnDisplay },
};
} else if (
'type' in toolResult.returnDisplay &&
toolResult.returnDisplay.type === 'todo_list'
) {
// Handle TodoResultDisplay - convert to text representation
const todoText = toolResult.returnDisplay.todos
.map((todo) => {
const statusIcon = {
pending: '○',
in_progress: '◐',
completed: '●',
}[todo.status];
return `${statusIcon} ${todo.content}`;
})
.join('\n');
return {
type: 'content',
content: { type: 'text', text: todoText },
};
} else if (
'type' in toolResult.returnDisplay &&
toolResult.returnDisplay.type === 'plan_summary'

View File

@@ -30,7 +30,6 @@ export {
logExtensionEnable,
logIdeConnection,
logExtensionDisable,
logAuth,
} from './src/telemetry/loggers.js';
export {
@@ -41,7 +40,6 @@ export {
ExtensionEnableEvent,
ExtensionUninstallEvent,
ModelSlashCommandEvent,
AuthEvent,
} from './src/telemetry/types.js';
export { makeFakeConfig } from './src/test-utils/config.js';
export * from './src/utils/pathReader.js';

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.2.2",
"version": "0.0.14",
"description": "Qwen Code Core",
"repository": {
"type": "git",
@@ -14,16 +14,14 @@
"format": "prettier --write .",
"test": "vitest run",
"test:ci": "vitest run",
"typecheck": "tsc --noEmit",
"postinstall": "node scripts/postinstall.js"
"typecheck": "tsc --noEmit"
},
"files": [
"dist",
"vendor",
"scripts/postinstall.js"
"dist"
],
"dependencies": {
"@google/genai": "1.16.0",
"@joshua.litt/get-ripgrep": "^0.0.2",
"@modelcontextprotocol/sdk": "^1.11.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",

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