Compare commits

..

29 Commits

Author SHA1 Message Date
tanzhenxin
442a9aed58 Replace spawn with execFile for memory-safe command execution (#1068) 2025-11-20 15:04:00 +08:00
Mingholy
a15b84e2a1 refactor(auth): enhance useAuthCommand to include history management and improve error handling in QwenOAuth2Client (#1077) 2025-11-20 14:37:39 +08:00
tanzhenxin
07069f00d1 feat: remove prompt completion feature (#1076) 2025-11-20 14:36:51 +08:00
pomelo
e1e7a0d606 Merge pull request #1074 from cwtuan/patch-1
fix: remove broken link
2025-11-20 14:33:14 +08:00
cwtuan
fc638851e7 fix: remove broken link 2025-11-20 12:50:06 +08:00
citlalinda
e1f793b2e0 fix: character encoding corruption when executing the /copy command on Windows. (#1069)
Co-authored-by: linda <hxn@163.com>
2025-11-20 10:23:17 +08:00
tanzhenxin
3c64f7bff5 chore: pump version to 0.2.3 (#1073) 2025-11-20 10:09:12 +08:00
tanzhenxin
97bf48b14c fix: skip problematic integration test (#1065) 2025-11-19 11:55:19 +08:00
Mingholy
d0e76c76a8 refactor(auth): save authType after successfully authenticated (#1036) 2025-11-19 11:21:46 +08:00
tanzhenxin
3ed93d5b5d fix: integration tests (#1062) 2025-11-19 10:23:16 +08:00
tanzhenxin
71646490f1 Fix: Improve ripgrep binary detection and cross-platform compatibility (#1060) 2025-11-18 19:38:30 +08:00
DS-Controller2
f0bbeac04a fix(core): add modelscope provider to handle stream_options (#848)
* fix(core): add modelscope provider to handle stream_options

---------

Co-authored-by: Qwen Code <qwen-code@alibaba-inc.com>
Co-authored-by: mingholy.lmh <mingholy.lmh@alibaba-inc.com>
2025-11-18 13:47:20 +08:00
Mingholy
efca0bc795 fix: basic slash command support (#1020) 2025-11-18 13:46:42 +08:00
tanzhenxin
6bb829f876 feat: Add Terminal Attention Notifications for User Alerts (#1052) 2025-11-18 13:43:43 +08:00
tanzhenxin
5bc309b3dc feat: add os platform and version in log report (#1053) 2025-11-18 13:43:17 +08:00
yyyanghj
0eeffc6875 feat: add support for Trae editor (#1037) 2025-11-17 10:58:33 +08:00
hj C
f0e21374c1 feat: add support for alternative cached_tokens format in OpenAI converter (#1035)
Co-authored-by: chenhuanjie <chenhuanjie@xiaohongshu.com>
2025-11-14 18:09:33 +08:00
BlockHand
29261c75e1 feat: openApi configurable window (#1019) 2025-11-14 10:18:57 +08:00
tanzhenxin
b4eba6584a chore: pump version to 0.2.2 (#1027) 2025-11-13 20:39:14 +08:00
XinlongWu
e6d08f0596 Change deepseek token limits regex patterns for deepseek-chat (#817) 2025-11-13 19:12:10 +08:00
tanzhenxin
160b64523e Add Interactive Approval Mode Dialog (#1012) 2025-11-13 19:02:53 +08:00
tanzhenxin
0752a31e1e 🎯 PR: Improve Edit Tool Reliability with Fuzzy Matching Pipeline (#1025) 2025-11-13 19:01:09 +08:00
fffly.Zzz
b029f0d2ce docs: correct YAML list format for tools field in agent template (#1026)
Co-authored-by: zhangxiao <xiao.zhang@ucloud.cn>
2025-11-13 17:41:22 +08:00
tanzhenxin
d5d96c726a fix: print request errors for logging only in debug mode (#1006) 2025-11-12 19:46:28 +08:00
tanzhenxin
06141cda8d Refactor: Standardize Tool Naming and Configuration System (#1004) 2025-11-12 19:46:05 +08:00
tanzhenxin
22edef0cb9 chore: pump version to 0.2.1 (#1005) 2025-11-10 15:18:59 +08:00
pomelo
ca1ae19715 Merge pull request #996 from wrapss/windows-newline-fix
fix: Stream parsing for Windows Zed integration
2025-11-10 09:52:47 +08:00
Matthieu Beaumont
6aaac12d70 fix(acp): replace EOL with newline for content splitting
- Replace `EOL` from `node:os` with `\n` for consistent line splitting in ACP connection output processing
- This ensures cross-platform compatibility since `EOL` is platform-specific while `\n` is universally used in text decoding
- The change maintains the same behavior on all platforms by using standard newline characters
2025-11-08 14:54:43 +01:00
Mingholy
3c01c7153b feat: enhance zed integration with TodoWriteTool and TaskTool support (#992)
- Implemented detection and handling for TodoWriteTool to route updates as plan entries instead of tool call events.
- Added sub-agent tool tracking for TaskTool, allowing for event emission and cleanup.
- Updated event listeners to manage sub-agent tool calls and approval requests effectively.
2025-11-07 19:55:23 +08:00
123 changed files with 4669 additions and 3544 deletions

View File

@@ -25,7 +25,7 @@
</div> </div>
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance. Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
## 💡 Free Options Available ## 💡 Free Options Available

View File

@@ -578,7 +578,7 @@ Arguments passed directly when running the CLI can override other configurations
- Example: `qwen --approval-mode auto-edit` - Example: `qwen --approval-mode auto-edit`
- **`--allowed-tools <tool1,tool2,...>`**: - **`--allowed-tools <tool1,tool2,...>`**:
- A comma-separated list of tool names that will bypass the confirmation dialog. - A comma-separated list of tool names that will bypass the confirmation dialog.
- Example: `qwen --allowed-tools "ShellTool(git status)"` - Example: `qwen --allowed-tools "Shell(git status)"`
- **`--telemetry`**: - **`--telemetry`**:
- Enables [telemetry](../telemetry.md). - Enables [telemetry](../telemetry.md).
- **`--telemetry-target`**: - **`--telemetry-target`**:

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. - **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: - **Tool Registry (`tool-registry.ts`):** A class (`ToolRegistry`) responsible for:
- **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ReadFileTool`, `ShellTool`). - **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ListFiles`, `ReadFile`).
- **Discovering Tools:** It can also discover tools dynamically: - **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. - **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`). - **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,20 +33,24 @@ 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: The core comes with a suite of pre-defined tools, typically found in `packages/core/src/tools/`. These include:
- **File System Tools:** - **File System Tools:**
- `LSTool` (`ls.ts`): Lists directory contents. - `ListFiles` (`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. - `ReadFile` (`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. - `WriteFile` (`write-file.ts`): Writes content to a file.
- `GrepTool` (`grep.ts`): Searches for patterns in files. - `ReadManyFiles` (`read-many-files.ts`): Reads and concatenates content from multiple files or glob patterns (used by the `@` command in CLI).
- `GlobTool` (`glob.ts`): Finds files matching glob patterns. - `Grep` (`grep.ts`): Searches for patterns in files.
- `EditTool` (`edit.ts`): Performs in-place modifications to files (often requiring confirmation). - `Glob` (`glob.ts`): Finds files matching glob patterns.
- `ReadManyFilesTool` (`read-many-files.ts`): Reads and concatenates content from multiple files or glob patterns (used by the `@` command in CLI). - `Edit` (`edit.ts`): Performs in-place modifications to files (often requiring confirmation).
- **Execution Tools:** - **Execution Tools:**
- `ShellTool` (`shell.ts`): Executes arbitrary shell commands (requires careful sandboxing and user confirmation). - `Shell` (`shell.ts`): Executes arbitrary shell commands (requires careful sandboxing and user confirmation).
- **Web Tools:** - **Web Tools:**
- `WebFetchTool` (`web-fetch.ts`): Fetches content from a URL. - `WebFetch` (`web-fetch.ts`): Fetches content from a URL.
- `WebSearchTool` (`web-search.ts`): Performs a web search. - `WebSearch` (`web-search.ts`): Performs a web search.
- **Memory Tools:** - **Memory Tools:**
- `MemoryTool` (`memoryTool.ts`): Interacts with the AI's memory. - `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.
Each of these tools extends `BaseTool` and implements the required methods for its specific functionality. Each of these tools extends `BaseTool` and implements the required methods for its specific functionality.

View File

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

View File

@@ -14,6 +14,13 @@ 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. - **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` - 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) ## Frequently asked questions (FAQs)
- **Q: How do I update Qwen Code to the latest version?** - **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. **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` (ReadFolder) ## 1. `list_directory` (ListFiles)
`list_directory` lists the names of files and subdirectories directly within a specified directory path. It can optionally ignore entries matching provided glob patterns. `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` - **Tool name:** `list_directory`
- **Display name:** ReadFolder - **Display name:** ListFiles
- **File:** `ls.ts` - **File:** `ls.ts`
- **Parameters:** - **Parameters:**
- `path` (string, required): The absolute path to the directory to list. - `path` (string, required): The absolute path to the directory to list.
@@ -59,12 +59,12 @@ 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`. - **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. - **Confirmation:** Yes. Shows a diff of changes and asks for user approval before writing.
## 4. `glob` (FindFiles) ## 4. `glob` (Glob)
`glob` finds files matching specific glob patterns (e.g., `src/**/*.ts`, `*.md`), returning absolute paths sorted by modification time (newest first). `glob` finds files matching specific glob patterns (e.g., `src/**/*.ts`, `*.md`), returning absolute paths sorted by modification time (newest first).
- **Tool name:** `glob` - **Tool name:** `glob`
- **Display name:** FindFiles - **Display name:** Glob
- **File:** `glob.ts` - **File:** `glob.ts`
- **Parameters:** - **Parameters:**
- `pattern` (string, required): The glob pattern to match against (e.g., `"*.py"`, `"src/**/*.js"`). - `pattern` (string, required): The glob pattern to match against (e.g., `"*.py"`, `"src/**/*.js"`).
@@ -132,7 +132,7 @@ grep_search(pattern="function", glob="*.js", limit=10)
## 6. `edit` (Edit) ## 6. `edit` (Edit)
`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. `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.
- **Tool name:** `edit` - **Tool name:** `edit`
- **Display name:** Edit - **Display name:** Edit
@@ -144,12 +144,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. **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. - `new_string` (string, required): The exact literal text to replace `old_string` with.
- `expected_replacements` (number, optional): The number of occurrences to replace. Defaults to `1`. - `replace_all` (boolean, optional): Replace all occurrences of `old_string`. Defaults to `false`.
- **Behavior:** - **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 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 of `old_string`. - If `old_string` is provided, it reads the `file_path` and attempts to find exactly one occurrence unless `replace_all` is true.
- If one occurrence is found, it replaces it with `new_string`. - If the match is unique (or `replace_all` is true), it replaces the text 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. - **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`). - 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. - 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 +158,10 @@ grep_search(pattern="function", glob="*.js", limit=10)
- `old_string` is not empty, but the `file_path` does not exist. - `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 empty, but the `file_path` already exists.
- `old_string` is not found in the file after attempts to correct it. - `old_string` is not found in the file after attempts to correct it.
- `old_string` is found multiple times, and the self-correction mechanism cannot resolve it to a single, unambiguous match. - `old_string` is found multiple times, `replace_all` is false, and the self-correction mechanism cannot resolve it to a single, unambiguous match.
- **Output (`llmContent`):** - **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 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, expected 1 occurrences but found 2...`). - 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...`).
- **Confirmation:** Yes. Shows a diff of the proposed changes and asks for user approval before writing to the file. - **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. These file system tools provide a foundation for Qwen Code to understand and interact with your local project context.

View File

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

View File

@@ -22,21 +22,19 @@ describe('Interactive file system', () => {
'should perform a read-then-write sequence in interactive mode', 'should perform a read-then-write sequence in interactive mode',
async () => { async () => {
const fileName = 'version.txt'; const fileName = 'version.txt';
await rig.setup('interactive-read-then-write'); await rig.setup('interactive-read-then-write', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
rig.createFile(fileName, '1.0.0'); rig.createFile(fileName, '1.0.0');
const { ptyProcess } = rig.runInteractive(); 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 // Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000); const isReady = await rig.waitForText('Type your message', 15000);
expect( expect(

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.1.5", "version": "0.2.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.1.5", "version": "0.2.3",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -16024,7 +16024,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.1.5", "version": "0.2.3",
"dependencies": { "dependencies": {
"@google/genai": "1.16.0", "@google/genai": "1.16.0",
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
@@ -16139,7 +16139,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@qwen-code/qwen-code-core", "name": "@qwen-code/qwen-code-core",
"version": "0.1.5", "version": "0.2.3",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@google/genai": "1.16.0", "@google/genai": "1.16.0",
@@ -16278,7 +16278,7 @@
}, },
"packages/test-utils": { "packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils", "name": "@qwen-code/qwen-code-test-utils",
"version": "0.1.5", "version": "0.2.3",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
@@ -16290,7 +16290,7 @@
}, },
"packages/vscode-ide-companion": { "packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion", "name": "qwen-code-vscode-ide-companion",
"version": "0.1.5", "version": "0.2.3",
"license": "LICENSE", "license": "LICENSE",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1", "@modelcontextprotocol/sdk": "^1.15.1",

View File

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

View File

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

View File

@@ -789,7 +789,6 @@ export async function loadCliConfig(
useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep, useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep,
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell, shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
skipLoopDetection: settings.model?.skipLoopDetection ?? false, skipLoopDetection: settings.model?.skipLoopDetection ?? false,
skipStartupContext: settings.model?.skipStartupContext ?? false, skipStartupContext: settings.model?.skipStartupContext ?? false,
vlmSwitchMode, vlmSwitchMode,

View File

@@ -77,7 +77,6 @@ const MIGRATION_MAP: Record<string, string> = {
disableAutoUpdate: 'general.disableAutoUpdate', disableAutoUpdate: 'general.disableAutoUpdate',
disableUpdateNag: 'general.disableUpdateNag', disableUpdateNag: 'general.disableUpdateNag',
dnsResolutionOrder: 'advanced.dnsResolutionOrder', dnsResolutionOrder: 'advanced.dnsResolutionOrder',
enablePromptCompletion: 'general.enablePromptCompletion',
enforcedAuthType: 'security.auth.enforcedType', enforcedAuthType: 'security.auth.enforcedType',
excludeTools: 'tools.exclude', excludeTools: 'tools.exclude',
excludeMCPServers: 'mcp.excluded', excludeMCPServers: 'mcp.excluded',
@@ -839,5 +838,6 @@ export function saveSettings(settingsFile: SettingsFile): void {
); );
} catch (error) { } catch (error) {
console.error('Error saving user settings file:', error); console.error('Error saving user settings file:', error);
throw error;
} }
} }

View File

@@ -12,6 +12,7 @@ import type {
ChatCompressionSettings, ChatCompressionSettings,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { import {
ApprovalMode,
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
@@ -166,16 +167,6 @@ const SETTINGS_SCHEMA = {
}, },
}, },
}, },
enablePromptCompletion: {
type: 'boolean',
label: 'Enable Prompt Completion',
category: 'General',
requiresRestart: true,
default: false,
description:
'Enable AI-powered prompt completion suggestions while typing.',
showInDialog: true,
},
debugKeystrokeLogging: { debugKeystrokeLogging: {
type: 'boolean', type: 'boolean',
label: 'Debug Keystroke Logging', label: 'Debug Keystroke Logging',
@@ -830,14 +821,20 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.UNION, mergeStrategy: MergeStrategy.UNION,
}, },
approvalMode: { approvalMode: {
type: 'string', type: 'enum',
label: 'Default Approval Mode', label: 'Approval Mode',
category: 'Tools', category: 'Tools',
requiresRestart: false, requiresRestart: false,
default: 'default', default: ApprovalMode.DEFAULT,
description: description:
'Default approval mode for tool usage. Valid values: plan, default, auto-edit, yolo.', 'Approval mode for tool usage. Controls how tools are approved before execution.',
showInDialog: true, 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: { discoveryCommand: {
type: 'string', type: 'string',

View File

@@ -8,6 +8,8 @@ import {
type AuthType, type AuthType,
type Config, type Config,
getErrorMessage, getErrorMessage,
logAuth,
AuthEvent,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
/** /**
@@ -25,11 +27,21 @@ export async function performInitialAuth(
} }
try { try {
await config.refreshAuth(authType); await config.refreshAuth(authType, true);
// The console.log is intentionally left out here. // The console.log is intentionally left out here.
// We can add a dedicated startup message later if needed. // 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) { } catch (e) {
return `Failed to login. Message: ${getErrorMessage(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 null; return null;

View File

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

View File

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

View File

@@ -13,15 +13,56 @@ import {
type Config, type Config,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { CommandService } from './services/CommandService.js'; import { CommandService } from './services/CommandService.js';
import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
import { FileCommandLoader } from './services/FileCommandLoader.js'; import { FileCommandLoader } from './services/FileCommandLoader.js';
import type { CommandContext } from './ui/commands/types.js'; import {
CommandKind,
type CommandContext,
type SlashCommand,
} from './ui/commands/types.js';
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js'; import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
import type { LoadedSettings } from './config/settings.js'; import type { LoadedSettings } from './config/settings.js';
import type { SessionStatsState } from './ui/contexts/SessionContext.js'; import type { SessionStatsState } from './ui/contexts/SessionContext.js';
/**
* 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. * 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 * @returns A Promise that resolves to `PartListUnion` if a valid command is
* found and results in a prompt, or `undefined` otherwise. * found and results in a prompt, or `undefined` otherwise.
* @throws {FatalInputError} if the command result is not supported in * @throws {FatalInputError} if the command result is not supported in
@@ -32,21 +73,35 @@ export const handleSlashCommand = async (
abortController: AbortController, abortController: AbortController,
config: Config, config: Config,
settings: LoadedSettings, settings: LoadedSettings,
allowedBuiltinCommandNames?: string[],
): Promise<PartListUnion | undefined> => { ): Promise<PartListUnion | undefined> => {
const trimmed = rawQuery.trim(); const trimmed = rawQuery.trim();
if (!trimmed.startsWith('/')) { if (!trimmed.startsWith('/')) {
return; return;
} }
// Only custom commands are supported for now. const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
const loaders = [new FileCommandLoader(config)];
// 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( const commandService = await CommandService.create(
loaders, loaders,
abortController.signal, abortController.signal,
); );
const commands = commandService.getCommands(); const commands = commandService.getCommands();
const filteredCommands = filterCommandsForNonInteractive(
commands,
allowedBuiltinSet,
);
const { commandToExecute, args } = parseSlashCommand(rawQuery, commands); const { commandToExecute, args } = parseSlashCommand(
rawQuery,
filteredCommands,
);
if (commandToExecute) { if (commandToExecute) {
if (commandToExecute.action) { if (commandToExecute.action) {
@@ -107,3 +162,44 @@ export const handleSlashCommand = async (
return; 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

@@ -25,7 +25,6 @@ import {
type HistoryItem, type HistoryItem,
ToolCallStatus, ToolCallStatus,
type HistoryItemWithoutId, type HistoryItemWithoutId,
AuthState,
} from './types.js'; } from './types.js';
import { MessageType, StreamingState } from './types.js'; import { MessageType, StreamingState } from './types.js';
import { import {
@@ -48,11 +47,11 @@ import { useHistory } from './hooks/useHistoryManager.js';
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js'; import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
import { useThemeCommand } from './hooks/useThemeCommand.js'; import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useAuthCommand } from './auth/useAuth.js'; import { useAuthCommand } from './auth/useAuth.js';
import { useQwenAuth } from './hooks/useQwenAuth.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js';
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useVimMode } from './contexts/VimModeContext.js'; import { useVimMode } from './contexts/VimModeContext.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js';
@@ -92,10 +91,12 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js'; import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js'; import { useDialogClose } from './hooks/useDialogClose.js';
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js'; import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js';
import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000; const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
@@ -335,25 +336,24 @@ export const AppContainer = (props: AppContainerProps) => {
initializationResult.themeError, initializationResult.themeError,
); );
const {
isApprovalModeDialogOpen,
openApprovalModeDialog,
handleApprovalModeSelect,
} = useApprovalModeCommand(settings, config);
const { const {
setAuthState, setAuthState,
authError, authError,
onAuthError, onAuthError,
isAuthDialogOpen, isAuthDialogOpen,
isAuthenticating, isAuthenticating,
pendingAuthType,
qwenAuthState,
handleAuthSelect, handleAuthSelect,
openAuthDialog, openAuthDialog,
} = useAuthCommand(settings, config); cancelAuthentication,
} = useAuthCommand(settings, config, historyManager.addItem);
// Qwen OAuth authentication state
const {
isQwenAuth,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
cancelQwenAuth,
} = useQwenAuth(settings, isAuthenticating);
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({ const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
config, config,
@@ -363,19 +363,7 @@ export const AppContainer = (props: AppContainerProps) => {
setModelSwitchedFromQuotaError, setModelSwitchedFromQuotaError,
}); });
// Handle Qwen OAuth timeout useInitializationAuthError(initializationResult.authError, onAuthError);
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 // Sync user tier from config when authentication changes
// TODO: Implement getUserTier() method on Config if needed // TODO: Implement getUserTier() method on Config if needed
@@ -387,6 +375,8 @@ export const AppContainer = (props: AppContainerProps) => {
// Check for enforced auth type mismatch // Check for enforced auth type mismatch
useEffect(() => { useEffect(() => {
// Check for initialization error first
if ( if (
settings.merged.security?.auth?.enforcedType && settings.merged.security?.auth?.enforcedType &&
settings.merged.security?.auth.selectedType && settings.merged.security?.auth.selectedType &&
@@ -470,6 +460,7 @@ export const AppContainer = (props: AppContainerProps) => {
openSettingsDialog, openSettingsDialog,
openModelDialog, openModelDialog,
openPermissionsDialog, openPermissionsDialog,
openApprovalModeDialog,
quit: (messages: HistoryItem[]) => { quit: (messages: HistoryItem[]) => {
setQuittingMessages(messages); setQuittingMessages(messages);
setTimeout(async () => { setTimeout(async () => {
@@ -495,6 +486,7 @@ export const AppContainer = (props: AppContainerProps) => {
setCorgiMode, setCorgiMode,
dispatchExtensionStateUpdate, dispatchExtensionStateUpdate,
openPermissionsDialog, openPermissionsDialog,
openApprovalModeDialog,
addConfirmUpdateExtensionRequest, addConfirmUpdateExtensionRequest,
showQuitConfirmation, showQuitConfirmation,
openSubagentCreateDialog, openSubagentCreateDialog,
@@ -935,13 +927,21 @@ export const AppContainer = (props: AppContainerProps) => {
settings.merged.ui?.customWittyPhrases, settings.merged.ui?.customWittyPhrases,
); );
useAttentionNotifications({
isFocused,
streamingState,
elapsedTime,
});
// Dialog close functionality // Dialog close functionality
const { closeAnyOpenDialog } = useDialogClose({ const { closeAnyOpenDialog } = useDialogClose({
isThemeDialogOpen, isThemeDialogOpen,
handleThemeSelect, handleThemeSelect,
isApprovalModeDialogOpen,
handleApprovalModeSelect,
isAuthDialogOpen, isAuthDialogOpen,
handleAuthSelect, handleAuthSelect,
selectedAuthType: settings.merged.security?.auth?.selectedType, pendingAuthType,
isEditorDialogOpen, isEditorDialogOpen,
exitEditorDialog, exitEditorDialog,
isSettingsDialogOpen, isSettingsDialogOpen,
@@ -1183,12 +1183,13 @@ export const AppContainer = (props: AppContainerProps) => {
isVisionSwitchDialogOpen || isVisionSwitchDialogOpen ||
isPermissionsDialogOpen || isPermissionsDialogOpen ||
isAuthDialogOpen || isAuthDialogOpen ||
(isAuthenticating && isQwenAuthenticating) || isAuthenticating ||
isEditorDialogOpen || isEditorDialogOpen ||
showIdeRestartPrompt || showIdeRestartPrompt ||
!!proQuotaRequest || !!proQuotaRequest ||
isSubagentCreateDialogOpen || isSubagentCreateDialogOpen ||
isAgentsManagerDialogOpen; isAgentsManagerDialogOpen ||
isApprovalModeDialogOpen;
const pendingHistoryItems = useMemo( const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
@@ -1205,12 +1206,9 @@ export const AppContainer = (props: AppContainerProps) => {
isConfigInitialized, isConfigInitialized,
authError, authError,
isAuthDialogOpen, isAuthDialogOpen,
pendingAuthType,
// Qwen OAuth state // Qwen OAuth state
isQwenAuth, qwenAuthState,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
editorError, editorError,
isEditorDialogOpen, isEditorDialogOpen,
corgiMode, corgiMode,
@@ -1219,6 +1217,7 @@ export const AppContainer = (props: AppContainerProps) => {
isSettingsDialogOpen, isSettingsDialogOpen,
isModelDialogOpen, isModelDialogOpen,
isPermissionsDialogOpen, isPermissionsDialogOpen,
isApprovalModeDialogOpen,
slashCommands, slashCommands,
pendingSlashCommandHistoryItems, pendingSlashCommandHistoryItems,
commandContext, commandContext,
@@ -1299,12 +1298,9 @@ export const AppContainer = (props: AppContainerProps) => {
isConfigInitialized, isConfigInitialized,
authError, authError,
isAuthDialogOpen, isAuthDialogOpen,
pendingAuthType,
// Qwen OAuth state // Qwen OAuth state
isQwenAuth, qwenAuthState,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
editorError, editorError,
isEditorDialogOpen, isEditorDialogOpen,
corgiMode, corgiMode,
@@ -1313,6 +1309,7 @@ export const AppContainer = (props: AppContainerProps) => {
isSettingsDialogOpen, isSettingsDialogOpen,
isModelDialogOpen, isModelDialogOpen,
isPermissionsDialogOpen, isPermissionsDialogOpen,
isApprovalModeDialogOpen,
slashCommands, slashCommands,
pendingSlashCommandHistoryItems, pendingSlashCommandHistoryItems,
commandContext, commandContext,
@@ -1393,12 +1390,11 @@ export const AppContainer = (props: AppContainerProps) => {
() => ({ () => ({
handleThemeSelect, handleThemeSelect,
handleThemeHighlight, handleThemeHighlight,
handleApprovalModeSelect,
handleAuthSelect, handleAuthSelect,
setAuthState, setAuthState,
onAuthError, onAuthError,
// Qwen OAuth handlers cancelAuthentication,
handleQwenAuthTimeout,
handleQwenAuthCancel,
handleEditorSelect, handleEditorSelect,
exitEditorDialog, exitEditorDialog,
closeSettingsDialog, closeSettingsDialog,
@@ -1428,12 +1424,11 @@ export const AppContainer = (props: AppContainerProps) => {
[ [
handleThemeSelect, handleThemeSelect,
handleThemeHighlight, handleThemeHighlight,
handleApprovalModeSelect,
handleAuthSelect, handleAuthSelect,
setAuthState, setAuthState,
onAuthError, onAuthError,
// Qwen OAuth handlers cancelAuthentication,
handleQwenAuthTimeout,
handleQwenAuthCancel,
handleEditorSelect, handleEditorSelect,
exitEditorDialog, exitEditorDialog,
closeSettingsDialog, closeSettingsDialog,

View File

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

View File

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

View File

@@ -4,31 +4,28 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useState, useCallback, useEffect } from 'react'; import type { Config } from '@qwen-code/qwen-code-core';
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import type { AuthType, Config } from '@qwen-code/qwen-code-core';
import { import {
AuthEvent,
AuthType,
clearCachedCredentialFile, clearCachedCredentialFile,
getErrorMessage, getErrorMessage,
logAuth,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { AuthState } from '../types.js'; import { useCallback, useEffect, useState } from 'react';
import { validateAuthMethod } from '../../config/auth.js'; import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { AuthState, MessageType } from '../types.js';
import type { HistoryItem } from '../types.js';
export function validateAuthMethodWithSettings( export type { QwenAuthState } from '../hooks/useQwenAuth.js';
authType: AuthType,
export const useAuthCommand = (
settings: LoadedSettings, settings: LoadedSettings,
): string | null { config: Config,
const enforcedType = settings.merged.security?.auth?.enforcedType; addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
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 = const unAuthenticated =
settings.merged.security?.auth?.selectedType === undefined; settings.merged.security?.auth?.selectedType === undefined;
@@ -40,6 +37,14 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
const [isAuthenticating, setIsAuthenticating] = useState(false); const [isAuthenticating, setIsAuthenticating] = useState(false);
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated); const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated);
const [pendingAuthType, setPendingAuthType] = useState<AuthType | undefined>(
undefined,
);
const { qwenAuthState, cancelQwenAuth } = useQwenAuth(
pendingAuthType,
isAuthenticating,
);
const onAuthError = useCallback( const onAuthError = useCallback(
(error: string | null) => { (error: string | null) => {
@@ -52,90 +57,132 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
[setAuthError, setAuthState], [setAuthError, setAuthState],
); );
// Authentication flow const handleAuthFailure = useCallback(
useEffect(() => { (error: unknown) => {
const authFlow = async () => { setIsAuthenticating(false);
const authType = settings.merged.security?.auth?.selectedType; const errorMessage = `Failed to authenticate. Message: ${getErrorMessage(error)}`;
if (isAuthDialogOpen || !authType) { onAuthError(errorMessage);
return;
// Log authentication failure
if (pendingAuthType) {
const authEvent = new AuthEvent(
pendingAuthType,
'manual',
'error',
errorMessage,
);
logAuth(config, authEvent);
} }
},
[onAuthError, pendingAuthType, config],
);
const validationError = validateAuthMethodWithSettings( const handleAuthSuccess = useCallback(
authType,
settings,
);
if (validationError) {
onAuthError(validationError);
return;
}
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 ( async (
authType: AuthType | undefined, authType: AuthType,
scope: SettingScope, scope: SettingScope,
credentials?: { credentials?: OpenAICredentials,
apiKey?: string;
baseUrl?: string;
model?: string;
},
) => { ) => {
if (authType) { try {
await clearCachedCredentialFile(); settings.setValue(scope, 'security.auth.selectedType', authType);
// Save OpenAI credentials if provided // Only update credentials if not switching to QWEN_OAUTH,
if (credentials) { // so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
// Update Config's internal generationConfig before calling refreshAuth if (authType !== AuthType.QWEN_OAUTH && credentials) {
// This ensures refreshAuth has access to the new credentials if (credentials?.apiKey != null) {
config.updateCredentials({
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
});
// Also set environment variables for compatibility with other parts of the code
if (credentials.apiKey) {
settings.setValue( settings.setValue(
scope, scope,
'security.auth.apiKey', 'security.auth.apiKey',
credentials.apiKey, credentials.apiKey,
); );
} }
if (credentials.baseUrl) { if (credentials?.baseUrl != null) {
settings.setValue( settings.setValue(
scope, scope,
'security.auth.baseUrl', 'security.auth.baseUrl',
credentials.baseUrl, credentials.baseUrl,
); );
} }
if (credentials.model) { if (credentials?.model != null) {
settings.setValue(scope, 'model.name', credentials.model); settings.setValue(scope, 'model.name', credentials.model);
} }
await clearCachedCredentialFile();
} }
} catch (error) {
settings.setValue(scope, 'security.auth.selectedType', authType); handleAuthFailure(error);
return;
} }
setIsAuthDialogOpen(false);
setAuthError(null); setAuthError(null);
setAuthState(AuthState.Authenticated);
setPendingAuthType(undefined);
setIsAuthDialogOpen(false);
setIsAuthenticating(false);
// Log authentication success
const authEvent = new AuthEvent(authType, 'manual', 'success');
logAuth(config, authEvent);
// Show success message
addItem(
{
type: MessageType.INFO,
text: `Authenticated successfully with ${authType} credentials.`,
},
Date.now(),
);
}, },
[settings, config], [settings, handleAuthFailure, config, addItem],
);
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],
); );
const openAuthDialog = useCallback(() => { const openAuthDialog = useCallback(() => {
@@ -143,8 +190,45 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
}, []); }, []);
const cancelAuthentication = useCallback(() => { 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); 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 { return {
authState, authState,
@@ -153,6 +237,8 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
onAuthError, onAuthError,
isAuthDialogOpen, isAuthDialogOpen,
isAuthenticating, isAuthenticating,
pendingAuthType,
qwenAuthState,
handleAuthSelect, handleAuthSelect,
openAuthDialog, openAuthDialog,
cancelAuthentication, cancelAuthentication,

View File

@@ -4,492 +4,68 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect } from 'vitest';
import { approvalModeCommand } from './approvalModeCommand.js'; import { approvalModeCommand } from './approvalModeCommand.js';
import { import {
type CommandContext, type CommandContext,
CommandKind, CommandKind,
type MessageActionReturn, type OpenDialogActionReturn,
} from './types.js'; } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js';
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
describe('approvalModeCommand', () => { describe('approvalModeCommand', () => {
let mockContext: CommandContext; 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(() => { beforeEach(() => {
setApprovalModeMock = vi.fn();
setSettingsValueMock = vi.fn();
mockContext = createMockCommandContext({ mockContext = createMockCommandContext({
services: { services: {
config: { config: {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getApprovalMode: () => 'default',
setApprovalMode: setApprovalModeMock, setApprovalMode: () => {},
}, },
settings: { settings: {
merged: {}, merged: {},
setValue: setSettingsValueMock, setValue: () => {},
forScope: vi forScope: () => ({}),
.fn()
.mockImplementation((scope: SettingScope) =>
scope === SettingScope.User
? userSettingsFile
: scope === SettingScope.Workspace
? projectSettingsFile
: { path: '', settings: {} },
),
} as unknown as LoadedSettings, } as unknown as LoadedSettings,
}, },
} as unknown as CommandContext); });
}); });
afterEach(() => { it('should have correct metadata', () => {
process.env = { ...originalEnv };
vi.clearAllMocks();
});
it('should have the correct command properties', () => {
expect(approvalModeCommand.name).toBe('approval-mode'); expect(approvalModeCommand.name).toBe('approval-mode');
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
expect(approvalModeCommand.description).toBe( expect(approvalModeCommand.description).toBe(
'View or change the approval mode for tool usage', 'View or change the approval mode for tool usage',
); );
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
}); });
it('should show current mode, options, and usage when no arguments provided', async () => { it('should open approval mode dialog when invoked', async () => {
if (!approvalModeCommand.action) { const result = (await approvalModeCommand.action?.(
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext, mockContext,
'', '',
)) as MessageActionReturn; )) as OpenDialogActionReturn;
expect(result.type).toBe('message'); expect(result.type).toBe('dialog');
expect(result.messageType).toBe('info'); expect(result.dialog).toBe('approval-mode');
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 display error when config is not available', async () => { it('should open approval mode dialog with arguments (ignored)', async () => {
if (!approvalModeCommand.action) { const result = (await approvalModeCommand.action?.(
throw new Error('approvalModeCommand must have an action.'); mockContext,
} 'some arguments',
)) as OpenDialogActionReturn;
const nullConfigContext = createMockCommandContext({ expect(result.type).toBe('dialog');
services: { expect(result.dialog).toBe('approval-mode');
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 () => { it('should not have subcommands', () => {
if (!approvalModeCommand.action) { expect(approvalModeCommand.subCommands).toBeUndefined();
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'plan',
)) as MessageActionReturn;
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 accept canonical auto-edit mode value', async () => { it('should not have completion function', () => {
if (!approvalModeCommand.action) { expect(approvalModeCommand.completion).toBeUndefined();
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 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,428 +7,19 @@
import type { import type {
SlashCommand, SlashCommand,
CommandContext, CommandContext,
MessageActionReturn, OpenDialogActionReturn,
} from './types.js'; } from './types.js';
import { CommandKind } 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 = { export const approvalModeCommand: SlashCommand = {
name: 'approval-mode', name: 'approval-mode',
description: 'View or change the approval mode for tool usage', description: 'View or change the approval mode for tool usage',
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
action: async ( action: async (
context: CommandContext, _context: CommandContext,
args: string, _args: string,
): Promise<MessageActionReturn> => { ): Promise<OpenDialogActionReturn> => ({
const { config } = context.services; type: 'dialog',
if (!config) { dialog: 'approval-mode',
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

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

View File

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

View File

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

View File

@@ -164,11 +164,6 @@ describe('InputPrompt', () => {
setActiveSuggestionIndex: vi.fn(), setActiveSuggestionIndex: vi.fn(),
setShowSuggestions: vi.fn(), setShowSuggestions: vi.fn(),
handleAutocomplete: vi.fn(), handleAutocomplete: vi.fn(),
promptCompletion: {
text: '',
accept: vi.fn(),
clear: vi.fn(),
},
}; };
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion); mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);

View File

@@ -12,9 +12,8 @@ import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js'; import { useInputHistory } from '../hooks/useInputHistory.js';
import type { TextBuffer } from './shared/text-buffer.js'; import type { TextBuffer } from './shared/text-buffer.js';
import { logicalPosToOffset } from './shared/text-buffer.js'; import { logicalPosToOffset } from './shared/text-buffer.js';
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js'; import { cpSlice, cpLen } from '../utils/textUtils.js';
import chalk from 'chalk'; import chalk from 'chalk';
import stringWidth from 'string-width';
import { useShellHistory } from '../hooks/useShellHistory.js'; import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js'; import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js'; import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
@@ -91,7 +90,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
commandContext, commandContext,
placeholder = ' Type your message or @path/to/file', placeholder = ' Type your message or @path/to/file',
focus = true, focus = true,
inputWidth,
suggestionsWidth, suggestionsWidth,
shellModeActive, shellModeActive,
setShellModeActive, setShellModeActive,
@@ -526,16 +524,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
} }
// Handle Tab key for ghost text acceptance
if (
key.name === 'tab' &&
!completion.showSuggestions &&
completion.promptCompletion.text
) {
completion.promptCompletion.accept();
return;
}
if (!shellModeActive) { if (!shellModeActive) {
if (keyMatchers[Command.REVERSE_SEARCH](key)) { if (keyMatchers[Command.REVERSE_SEARCH](key)) {
setCommandSearchActive(true); setCommandSearchActive(true);
@@ -657,18 +645,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Fall back to the text buffer's default input handling for all other keys // Fall back to the text buffer's default input handling for all other keys
buffer.handleInput(key); buffer.handleInput(key);
// Clear ghost text when user types regular characters (not navigation/control keys)
if (
completion.promptCompletion.text &&
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
completion.promptCompletion.clear();
setExpandedSuggestionIndex(-1);
}
}, },
[ [
focus, focus,
@@ -703,118 +679,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.visualCursor; buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow; const scrollVisualRow = buffer.visualScrollRow;
const getGhostTextLines = useCallback(() => {
if (
!completion.promptCompletion.text ||
!buffer.text ||
!completion.promptCompletion.text.startsWith(buffer.text)
) {
return { inlineGhost: '', additionalLines: [] };
}
const ghostSuffix = completion.promptCompletion.text.slice(
buffer.text.length,
);
if (!ghostSuffix) {
return { inlineGhost: '', additionalLines: [] };
}
const currentLogicalLine = buffer.lines[buffer.cursor[0]] || '';
const cursorCol = buffer.cursor[1];
const textBeforeCursor = cpSlice(currentLogicalLine, 0, cursorCol);
const usedWidth = stringWidth(textBeforeCursor);
const remainingWidth = Math.max(0, inputWidth - usedWidth);
const ghostTextLinesRaw = ghostSuffix.split('\n');
const firstLineRaw = ghostTextLinesRaw.shift() || '';
let inlineGhost = '';
let remainingFirstLine = '';
if (stringWidth(firstLineRaw) <= remainingWidth) {
inlineGhost = firstLineRaw;
} else {
const words = firstLineRaw.split(' ');
let currentLine = '';
let wordIdx = 0;
for (const word of words) {
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
if (stringWidth(prospectiveLine) > remainingWidth) {
break;
}
currentLine = prospectiveLine;
wordIdx++;
}
inlineGhost = currentLine;
if (words.length > wordIdx) {
remainingFirstLine = words.slice(wordIdx).join(' ');
}
}
const linesToWrap = [];
if (remainingFirstLine) {
linesToWrap.push(remainingFirstLine);
}
linesToWrap.push(...ghostTextLinesRaw);
const remainingGhostText = linesToWrap.join('\n');
const additionalLines: string[] = [];
if (remainingGhostText) {
const textLines = remainingGhostText.split('\n');
for (const textLine of textLines) {
const words = textLine.split(' ');
let currentLine = '';
for (const word of words) {
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
const prospectiveWidth = stringWidth(prospectiveLine);
if (prospectiveWidth > inputWidth) {
if (currentLine) {
additionalLines.push(currentLine);
}
let wordToProcess = word;
while (stringWidth(wordToProcess) > inputWidth) {
let part = '';
const wordCP = toCodePoints(wordToProcess);
let partWidth = 0;
let splitIndex = 0;
for (let i = 0; i < wordCP.length; i++) {
const char = wordCP[i];
const charWidth = stringWidth(char);
if (partWidth + charWidth > inputWidth) {
break;
}
part += char;
partWidth += charWidth;
splitIndex = i + 1;
}
additionalLines.push(part);
wordToProcess = cpSlice(wordToProcess, splitIndex);
}
currentLine = wordToProcess;
} else {
currentLine = prospectiveLine;
}
}
if (currentLine) {
additionalLines.push(currentLine);
}
}
}
return { inlineGhost, additionalLines };
}, [
completion.promptCompletion.text,
buffer.text,
buffer.lines,
buffer.cursor,
inputWidth,
]);
const { inlineGhost, additionalLines } = getGhostTextLines();
const getActiveCompletion = () => { const getActiveCompletion = () => {
if (commandSearchActive) return commandSearchCompletion; if (commandSearchActive) return commandSearchCompletion;
if (reverseSearchActive) return reverseSearchCompletion; if (reverseSearchActive) return reverseSearchCompletion;
@@ -887,134 +751,96 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<Text color={theme.text.secondary}>{placeholder}</Text> <Text color={theme.text.secondary}>{placeholder}</Text>
) )
) : ( ) : (
linesToRender linesToRender.map((lineText, visualIdxInRenderedSet) => {
.map((lineText, visualIdxInRenderedSet) => { const absoluteVisualIdx =
const absoluteVisualIdx = scrollVisualRow + visualIdxInRenderedSet;
scrollVisualRow + visualIdxInRenderedSet; const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx]; const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
const cursorVisualRow = const isOnCursorLine =
cursorVisualRowAbsolute - scrollVisualRow; focus && visualIdxInRenderedSet === cursorVisualRow;
const isOnCursorLine =
focus && visualIdxInRenderedSet === cursorVisualRow;
const renderedLine: React.ReactNode[] = []; const renderedLine: React.ReactNode[] = [];
const [logicalLineIdx, logicalStartCol] = mapEntry; const [logicalLineIdx, logicalStartCol] = mapEntry;
const logicalLine = buffer.lines[logicalLineIdx] || ''; const logicalLine = buffer.lines[logicalLineIdx] || '';
const tokens = parseInputForHighlighting( const tokens = parseInputForHighlighting(
logicalLine, logicalLine,
logicalLineIdx, logicalLineIdx,
); );
const visualStart = logicalStartCol; const visualStart = logicalStartCol;
const visualEnd = logicalStartCol + cpLen(lineText); const visualEnd = logicalStartCol + cpLen(lineText);
const segments = buildSegmentsForVisualSlice( const segments = buildSegmentsForVisualSlice(
tokens, tokens,
visualStart, visualStart,
visualEnd, visualEnd,
); );
let charCount = 0; let charCount = 0;
segments.forEach((seg, segIdx) => { segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text); const segLen = cpLen(seg.text);
let display = seg.text; let display = seg.text;
if (isOnCursorLine) { if (isOnCursorLine) {
const relativeVisualColForHighlight = const relativeVisualColForHighlight = cursorVisualColAbsolute;
cursorVisualColAbsolute; const segStart = charCount;
const segStart = charCount; const segEnd = segStart + segLen;
const segEnd = segStart + segLen; if (
if ( relativeVisualColForHighlight >= segStart &&
relativeVisualColForHighlight >= segStart && relativeVisualColForHighlight < segEnd
relativeVisualColForHighlight < segEnd ) {
) { const charToHighlight = cpSlice(
const charToHighlight = cpSlice( seg.text,
relativeVisualColForHighlight - segStart,
relativeVisualColForHighlight - segStart + 1,
);
const highlighted = showCursor
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(
seg.text, seg.text,
0,
relativeVisualColForHighlight - segStart, relativeVisualColForHighlight - segStart,
) +
highlighted +
cpSlice(
seg.text,
relativeVisualColForHighlight - segStart + 1, relativeVisualColForHighlight - segStart + 1,
); );
const highlighted = showCursor
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(
seg.text,
0,
relativeVisualColForHighlight - segStart,
) +
highlighted +
cpSlice(
seg.text,
relativeVisualColForHighlight - segStart + 1,
);
}
charCount = segEnd;
}
const color =
seg.type === 'command' || seg.type === 'file'
? theme.text.accent
: theme.text.primary;
renderedLine.push(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
});
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
if (
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
if (!currentLineGhost) {
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') : ' '}
</Text>,
);
} }
charCount = segEnd;
} }
const showCursorBeforeGhost = const color =
focus && seg.type === 'command' || seg.type === 'file'
isOnCursorLine && ? theme.text.accent
cursorVisualColAbsolute === cpLen(lineText) && : theme.text.primary;
currentLineGhost;
return ( renderedLine.push(
<Box key={`line-${visualIdxInRenderedSet}`} height={1}> <Text key={`token-${segIdx}`} color={color}>
<Text> {display}
{renderedLine} </Text>,
{showCursorBeforeGhost &&
(showCursor ? chalk.inverse(' ') : ' ')}
{currentLineGhost && (
<Text color={theme.text.secondary}>
{currentLineGhost}
</Text>
)}
</Text>
</Box>
); );
}) });
.concat(
additionalLines.map((ghostLine, index) => { if (
const padding = Math.max( isOnCursorLine &&
0, cursorVisualColAbsolute === cpLen(lineText)
inputWidth - stringWidth(ghostLine), ) {
); renderedLine.push(
return ( <Text key={`cursor-end-${cursorVisualColAbsolute}`}>
<Text {showCursor ? chalk.inverse(' ') : ' '}
key={`ghost-line-${index}`} </Text>,
color={theme.text.secondary} );
> }
{ghostLine}
{' '.repeat(padding)} return (
</Text> <Box key={`line-${visualIdxInRenderedSet}`} height={1}>
); <Text>{renderedLine}</Text>
}), </Box>
) );
})
)} )}
</Box> </Box>
</Box> </Box>

View File

@@ -6,6 +6,7 @@
import type React from 'react'; import type React from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { z } from 'zod';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
@@ -13,18 +14,62 @@ import { useKeypress } from '../hooks/useKeypress.js';
interface OpenAIKeyPromptProps { interface OpenAIKeyPromptProps {
onSubmit: (apiKey: string, baseUrl: string, model: string) => void; onSubmit: (apiKey: string, baseUrl: string, model: string) => void;
onCancel: () => 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({ export function OpenAIKeyPrompt({
onSubmit, onSubmit,
onCancel, onCancel,
defaultApiKey,
defaultBaseUrl,
defaultModel,
}: OpenAIKeyPromptProps): React.JSX.Element { }: OpenAIKeyPromptProps): React.JSX.Element {
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState(defaultApiKey || '');
const [baseUrl, setBaseUrl] = useState(''); const [baseUrl, setBaseUrl] = useState(defaultBaseUrl || '');
const [model, setModel] = useState(''); const [model, setModel] = useState(defaultModel || '');
const [currentField, setCurrentField] = useState< const [currentField, setCurrentField] = useState<
'apiKey' | 'baseUrl' | 'model' 'apiKey' | 'baseUrl' | 'model'
>('apiKey'); >('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( useKeypress(
(key) => { (key) => {
@@ -46,7 +91,7 @@ export function OpenAIKeyPrompt({
} else if (currentField === 'model') { } else if (currentField === 'model') {
// 只有在提交时才检查 API key 是否为空 // 只有在提交时才检查 API key 是否为空
if (apiKey.trim()) { if (apiKey.trim()) {
onSubmit(apiKey.trim(), baseUrl.trim(), model.trim()); validateAndSubmit();
} else { } else {
// 如果 API key 为空,回到 API key 字段 // 如果 API key 为空,回到 API key 字段
setCurrentField('apiKey'); setCurrentField('apiKey');
@@ -162,6 +207,11 @@ export function OpenAIKeyPrompt({
<Text bold color={Colors.AccentBlue}> <Text bold color={Colors.AccentBlue}>
OpenAI Configuration Required OpenAI Configuration Required
</Text> </Text>
{validationError && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{validationError}</Text>
</Box>
)}
<Box marginTop={1}> <Box marginTop={1}>
<Text> <Text>
Please enter your OpenAI configuration. You can get an API key from{' '} 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 { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QwenOAuthProgress } from './QwenOAuthProgress.js'; import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js'; import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import type { Key } from '../contexts/KeypressContext.js'; import type { Key } from '../contexts/KeypressContext.js';
@@ -42,12 +42,13 @@ describe('QwenOAuthProgress', () => {
let keypressHandler: ((key: Key) => void) | null = null; let keypressHandler: ((key: Key) => void) | null = null;
const createMockDeviceAuth = ( const createMockDeviceAuth = (
overrides: Partial<DeviceAuthorizationInfo> = {}, overrides: Partial<DeviceAuthorizationData> = {},
): DeviceAuthorizationInfo => ({ ): DeviceAuthorizationData => ({
verification_uri: 'https://example.com/device', verification_uri: 'https://example.com/device',
verification_uri_complete: 'https://example.com/device?user_code=ABC123', verification_uri_complete: 'https://example.com/device?user_code=ABC123',
user_code: 'ABC123', user_code: 'ABC123',
expires_in: 300, expires_in: 300,
device_code: 'test-device-code',
...overrides, ...overrides,
}); });
@@ -55,7 +56,7 @@ describe('QwenOAuthProgress', () => {
const renderComponent = ( const renderComponent = (
props: Partial<{ props: Partial<{
deviceAuth: DeviceAuthorizationInfo; deviceAuth: DeviceAuthorizationData;
authStatus: authStatus:
| 'idle' | 'idle'
| 'polling' | 'polling'
@@ -158,7 +159,7 @@ describe('QwenOAuthProgress', () => {
}); });
it('should format time correctly', () => { it('should format time correctly', () => {
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = { const deviceAuthWithCustomTime: DeviceAuthorizationData = {
...mockDeviceAuth, ...mockDeviceAuth,
expires_in: 125, // 2 minutes and 5 seconds expires_in: 125, // 2 minutes and 5 seconds
}; };
@@ -176,7 +177,7 @@ describe('QwenOAuthProgress', () => {
}); });
it('should format single digit seconds with leading zero', () => { it('should format single digit seconds with leading zero', () => {
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = { const deviceAuthWithCustomTime: DeviceAuthorizationData = {
...mockDeviceAuth, ...mockDeviceAuth,
expires_in: 67, // 1 minute and 7 seconds expires_in: 67, // 1 minute and 7 seconds
}; };
@@ -196,7 +197,7 @@ describe('QwenOAuthProgress', () => {
describe('Timer functionality', () => { describe('Timer functionality', () => {
it('should countdown and call onTimeout when timer expires', async () => { it('should countdown and call onTimeout when timer expires', async () => {
const deviceAuthWithShortTime: DeviceAuthorizationInfo = { const deviceAuthWithShortTime: DeviceAuthorizationData = {
...mockDeviceAuth, ...mockDeviceAuth,
expires_in: 2, // 2 seconds expires_in: 2, // 2 seconds
}; };
@@ -520,7 +521,7 @@ describe('QwenOAuthProgress', () => {
describe('Props changes', () => { describe('Props changes', () => {
it('should display initial timer value from deviceAuth', () => { it('should display initial timer value from deviceAuth', () => {
const deviceAuthWith10Min: DeviceAuthorizationInfo = { const deviceAuthWith10Min: DeviceAuthorizationData = {
...mockDeviceAuth, ...mockDeviceAuth,
expires_in: 600, // 10 minutes expires_in: 600, // 10 minutes
}; };

View File

@@ -11,13 +11,13 @@ import Spinner from 'ink-spinner';
import Link from 'ink-link'; import Link from 'ink-link';
import qrcode from 'qrcode-terminal'; import qrcode from 'qrcode-terminal';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js'; import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
interface QwenOAuthProgressProps { interface QwenOAuthProgressProps {
onTimeout: () => void; onTimeout: () => void;
onCancel: () => void; onCancel: () => void;
deviceAuth?: DeviceAuthorizationInfo; deviceAuth?: DeviceAuthorizationData;
authStatus?: authStatus?:
| 'idle' | 'idle'
| 'polling' | 'polling'
@@ -131,8 +131,8 @@ export function QwenOAuthProgress({
useKeypress( useKeypress(
(key) => { (key) => {
if (authStatus === 'timeout') { if (authStatus === 'timeout' || authStatus === 'error') {
// Any key press in timeout state should trigger cancel to return to auth dialog // Any key press in timeout or error state should trigger cancel to return to auth dialog
onCancel(); onCancel();
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { } else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
onCancel(); onCancel();
@@ -234,6 +234,35 @@ 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 // Show loading state when no device auth is available yet
if (!deviceAuth) { if (!deviceAuth) {
return ( return (

View File

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

View File

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

View File

@@ -10,8 +10,6 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
│ │ │ │
│ Disable Auto Update false │ │ Disable Auto Update false │
│ │ │ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging false │ │ Debug Keystroke Logging false │
│ │ │ │
│ Output Format Text │ │ Output Format Text │
@@ -22,13 +20,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
│ │ │ │
│ Hide Tips false │ │ Hide Tips false │
│ │ │ │
│ Hide Banner false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
│ Apply To │ │ Apply To │
│ ● User Settings │ │ ● User Settings │
│ Workspace Settings │ │ Workspace Settings │
│ System Settings │
│ │ │ │
│ (Use Enter to select, Tab to change focus) │ │ (Use Enter to select, Tab to change focus) │
│ │ │ │
@@ -45,8 +44,6 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
│ │ │ │
│ Disable Auto Update false │ │ Disable Auto Update false │
│ │ │ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging false │ │ Debug Keystroke Logging false │
│ │ │ │
│ Output Format Text │ │ Output Format Text │
@@ -57,13 +54,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
│ │ │ │
│ Hide Tips false │ │ Hide Tips false │
│ │ │ │
│ Hide Banner false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
│ Apply To │ │ Apply To │
│ ● User Settings │ │ ● User Settings │
│ Workspace Settings │ │ Workspace Settings │
│ System Settings │
│ │ │ │
│ (Use Enter to select, Tab to change focus) │ │ (Use Enter to select, Tab to change focus) │
│ │ │ │
@@ -80,8 +78,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
│ │ │ │
│ Disable Auto Update false │ │ Disable Auto Update false │
│ │ │ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging false │ │ Debug Keystroke Logging false │
│ │ │ │
│ Output Format Text │ │ Output Format Text │
@@ -92,13 +88,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
│ │ │ │
│ Hide Tips false │ │ Hide Tips false │
│ │ │ │
│ Hide Banner false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
│ Apply To │ │ Apply To │
│ ● User Settings │ │ ● User Settings │
│ Workspace Settings │ │ Workspace Settings │
│ System Settings │
│ │ │ │
│ (Use Enter to select, Tab to change focus) │ │ (Use Enter to select, Tab to change focus) │
│ │ │ │
@@ -115,8 +112,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
│ │ │ │
│ Disable Auto Update false* │ │ Disable Auto Update false* │
│ │ │ │
│ Enable Prompt Completion false* │
│ │
│ Debug Keystroke Logging false* │ │ Debug Keystroke Logging false* │
│ │ │ │
│ Output Format Text │ │ Output Format Text │
@@ -127,13 +122,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
│ │ │ │
│ Hide Tips false* │ │ Hide Tips false* │
│ │ │ │
│ Hide Banner false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
│ Apply To │ │ Apply To │
│ ● User Settings │ │ ● User Settings │
│ Workspace Settings │ │ Workspace Settings │
│ System Settings │
│ │ │ │
│ (Use Enter to select, Tab to change focus) │ │ (Use Enter to select, Tab to change focus) │
│ │ │ │
@@ -150,8 +146,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │ │ │
│ Disable Auto Update (Modified in System) false │ │ Disable Auto Update (Modified in System) false │
│ │ │ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging false │ │ Debug Keystroke Logging false │
│ │ │ │
│ Output Format Text │ │ Output Format Text │
@@ -162,13 +156,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │ │ │
│ Hide Tips false │ │ Hide Tips false │
│ │ │ │
│ Hide Banner false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
│ Apply To │ │ Apply To │
│ ● User Settings │ │ ● User Settings │
│ Workspace Settings │ │ Workspace Settings │
│ System Settings │
│ │ │ │
│ (Use Enter to select, Tab to change focus) │ │ (Use Enter to select, Tab to change focus) │
│ │ │ │
@@ -185,8 +180,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │ │ │
│ Disable Auto Update false │ │ Disable Auto Update false │
│ │ │ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging (Modified in Workspace) false │ │ Debug Keystroke Logging (Modified in Workspace) false │
│ │ │ │
│ Output Format Text │ │ Output Format Text │
@@ -197,13 +190,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │ │ │
│ Hide Tips false │ │ Hide Tips false │
│ │ │ │
│ Hide Banner false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
│ Apply To │ │ Apply To │
│ ● User Settings │ │ ● User Settings │
│ Workspace Settings │ │ Workspace Settings │
│ System Settings │
│ │ │ │
│ (Use Enter to select, Tab to change focus) │ │ (Use Enter to select, Tab to change focus) │
│ │ │ │
@@ -220,8 +214,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
│ │ │ │
│ Disable Auto Update false │ │ Disable Auto Update false │
│ │ │ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging false │ │ Debug Keystroke Logging false │
│ │ │ │
│ Output Format Text │ │ Output Format Text │
@@ -232,13 +224,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
│ │ │ │
│ Hide Tips false │ │ Hide Tips false │
│ │ │ │
│ Hide Banner false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
│ Apply To │ │ Apply To │
│ ● User Settings │ │ ● User Settings │
│ Workspace Settings │ │ Workspace Settings │
│ System Settings │
│ │ │ │
│ (Use Enter to select, Tab to change focus) │ │ (Use Enter to select, Tab to change focus) │
│ │ │ │
@@ -255,8 +248,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
│ │ │ │
│ Disable Auto Update true* │ │ Disable Auto Update true* │
│ │ │ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging false │ │ Debug Keystroke Logging false │
│ │ │ │
│ Output Format Text │ │ Output Format Text │
@@ -267,13 +258,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
│ │ │ │
│ Hide Tips false │ │ Hide Tips false │
│ │ │ │
│ Hide Banner false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
│ Apply To │ │ Apply To │
│ ● User Settings │ │ ● User Settings │
│ Workspace Settings │ │ Workspace Settings │
│ System Settings │
│ │ │ │
│ (Use Enter to select, Tab to change focus) │ │ (Use Enter to select, Tab to change focus) │
│ │ │ │
@@ -290,8 +282,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
│ │ │ │
│ Disable Auto Update false │ │ Disable Auto Update false │
│ │ │ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging false │ │ Debug Keystroke Logging false │
│ │ │ │
│ Output Format Text │ │ Output Format Text │
@@ -302,13 +292,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
│ │ │ │
│ Hide Tips false │ │ Hide Tips false │
│ │ │ │
│ Hide Banner false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
│ Apply To │ │ Apply To │
│ ● User Settings │ │ ● User Settings │
│ Workspace Settings │ │ Workspace Settings │
│ System Settings │
│ │ │ │
│ (Use Enter to select, Tab to change focus) │ │ (Use Enter to select, Tab to change focus) │
│ │ │ │
@@ -325,8 +316,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
│ │ │ │
│ Disable Auto Update true* │ │ Disable Auto Update true* │
│ │ │ │
│ Enable Prompt Completion true* │
│ │
│ Debug Keystroke Logging true* │ │ Debug Keystroke Logging true* │
│ │ │ │
│ Output Format Text │ │ Output Format Text │
@@ -337,13 +326,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
│ │ │ │
│ Hide Tips true* │ │ Hide Tips true* │
│ │ │ │
│ Hide Banner false │
│ │
│ ▼ │ │ ▼ │
│ │ │ │
│ │ │ │
│ Apply To │ │ Apply To │
│ ● User Settings │ │ ● User Settings │
│ Workspace Settings │ │ Workspace Settings │
│ System Settings │
│ │ │ │
│ (Use Enter to select, Tab to change focus) │ │ (Use Enter to select, Tab to change focus) │
│ │ │ │

View File

@@ -6,7 +6,6 @@ exports[`ThemeDialog Snapshots > should render correctly in scope selector mode
│ > Apply To │ │ > Apply To │
│ ● 1. User Settings │ │ ● 1. User Settings │
│ 2. Workspace Settings │ │ 2. Workspace Settings │
│ 3. System Settings │
│ │ │ │
│ (Use Enter to apply scope, Tab to select theme) │ │ (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: case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR:
return 'Could not compress chat history due to a token counting error.'; return 'Could not compress chat history due to a token counting error.';
case CompressionStatus.NOOP: case CompressionStatus.NOOP:
return 'Chat history is already compressed.'; return 'Nothing to compress.';
default: default:
return ''; return '';
} }

View File

@@ -330,7 +330,7 @@ describe('BaseSelectionList', () => {
expect(output).not.toContain('Item 5'); expect(output).not.toContain('Item 5');
}); });
it('should scroll up when activeIndex moves before the visible window', async () => { it.skip('should scroll up when activeIndex moves before the visible window', async () => {
const { updateActiveIndex, lastFrame } = renderScrollableList(0); const { updateActiveIndex, lastFrame } = renderScrollableList(0);
await updateActiveIndex(4); await updateActiveIndex(4);

View File

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

View File

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

View File

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

View File

@@ -48,6 +48,7 @@ interface SlashCommandProcessorActions {
openSettingsDialog: () => void; openSettingsDialog: () => void;
openModelDialog: () => void; openModelDialog: () => void;
openPermissionsDialog: () => void; openPermissionsDialog: () => void;
openApprovalModeDialog: () => void;
quit: (messages: HistoryItem[]) => void; quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void; setDebugMessage: (message: string) => void;
toggleCorgiMode: () => void; toggleCorgiMode: () => void;
@@ -396,6 +397,9 @@ export const useSlashCommandProcessor = (
case 'subagent_list': case 'subagent_list':
actions.openAgentsManagerDialog(); actions.openAgentsManagerDialog();
return { type: 'handled' }; return { type: 'handled' };
case 'approval-mode':
actions.openApprovalModeDialog();
return { type: 'handled' };
case 'help': case 'help':
return { type: 'handled' }; return { type: 'handled' };
default: { default: {

View File

@@ -0,0 +1,57 @@
/**
* @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

@@ -0,0 +1,151 @@
/**
* @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

@@ -0,0 +1,63 @@
/**
* @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

@@ -83,9 +83,7 @@ const setupMocks = ({
describe('useCommandCompletion', () => { describe('useCommandCompletion', () => {
const mockCommandContext = {} as CommandContext; const mockCommandContext = {} as CommandContext;
const mockConfig = { const mockConfig = {} as Config;
getEnablePromptCompletion: () => false,
} as Config;
const testDirs: string[] = []; const testDirs: string[] = [];
const testRootDir = '/'; const testRootDir = '/';
@@ -516,81 +514,4 @@ describe('useCommandCompletion', () => {
); );
}); });
}); });
describe('prompt completion filtering', () => {
it('should not trigger prompt completion for line comments', async () => {
const mockConfig = {
getEnablePromptCompletion: () => true,
} as Config;
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest('// This is a line comment');
const completion = useCommandCompletion(
textBuffer,
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
);
return { ...completion, textBuffer };
});
// Should not trigger prompt completion for comments
expect(result.current.suggestions.length).toBe(0);
});
it('should not trigger prompt completion for block comments', async () => {
const mockConfig = {
getEnablePromptCompletion: () => true,
} as Config;
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest(
'/* This is a block comment */',
);
const completion = useCommandCompletion(
textBuffer,
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
);
return { ...completion, textBuffer };
});
// Should not trigger prompt completion for comments
expect(result.current.suggestions.length).toBe(0);
});
it('should trigger prompt completion for regular text when enabled', async () => {
const mockConfig = {
getEnablePromptCompletion: () => true,
} as Config;
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest(
'This is regular text that should trigger completion',
);
const completion = useCommandCompletion(
textBuffer,
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
);
return { ...completion, textBuffer };
});
// This test verifies that comments are filtered out while regular text is not
expect(result.current.textBuffer.text).toBe(
'This is regular text that should trigger completion',
);
});
});
}); });

View File

@@ -13,11 +13,6 @@ import { isSlashCommand } from '../utils/commandUtils.js';
import { toCodePoints } from '../utils/textUtils.js'; import { toCodePoints } from '../utils/textUtils.js';
import { useAtCompletion } from './useAtCompletion.js'; import { useAtCompletion } from './useAtCompletion.js';
import { useSlashCompletion } from './useSlashCompletion.js'; import { useSlashCompletion } from './useSlashCompletion.js';
import type { PromptCompletion } from './usePromptCompletion.js';
import {
usePromptCompletion,
PROMPT_COMPLETION_MIN_LENGTH,
} from './usePromptCompletion.js';
import type { Config } from '@qwen-code/qwen-code-core'; import type { Config } from '@qwen-code/qwen-code-core';
import { useCompletion } from './useCompletion.js'; import { useCompletion } from './useCompletion.js';
@@ -25,7 +20,6 @@ export enum CompletionMode {
IDLE = 'IDLE', IDLE = 'IDLE',
AT = 'AT', AT = 'AT',
SLASH = 'SLASH', SLASH = 'SLASH',
PROMPT = 'PROMPT',
} }
export interface UseCommandCompletionReturn { export interface UseCommandCompletionReturn {
@@ -41,7 +35,6 @@ export interface UseCommandCompletionReturn {
navigateUp: () => void; navigateUp: () => void;
navigateDown: () => void; navigateDown: () => void;
handleAutocomplete: (indexToUse: number) => void; handleAutocomplete: (indexToUse: number) => void;
promptCompletion: PromptCompletion;
} }
export function useCommandCompletion( export function useCommandCompletion(
@@ -126,32 +119,13 @@ export function useCommandCompletion(
} }
} }
// Check for prompt completion - only if enabled
const trimmedText = buffer.text.trim();
const isPromptCompletionEnabled =
config?.getEnablePromptCompletion() ?? false;
if (
isPromptCompletionEnabled &&
trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH &&
!isSlashCommand(trimmedText) &&
!trimmedText.includes('@')
) {
return {
completionMode: CompletionMode.PROMPT,
query: trimmedText,
completionStart: 0,
completionEnd: trimmedText.length,
};
}
return { return {
completionMode: CompletionMode.IDLE, completionMode: CompletionMode.IDLE,
query: null, query: null,
completionStart: -1, completionStart: -1,
completionEnd: -1, completionEnd: -1,
}; };
}, [cursorRow, cursorCol, buffer.lines, buffer.text, config]); }, [cursorRow, cursorCol, buffer.lines]);
useAtCompletion({ useAtCompletion({
enabled: completionMode === CompletionMode.AT, enabled: completionMode === CompletionMode.AT,
@@ -172,12 +146,6 @@ export function useCommandCompletion(
setIsPerfectMatch, setIsPerfectMatch,
}); });
const promptCompletion = usePromptCompletion({
buffer,
config,
enabled: completionMode === CompletionMode.PROMPT,
});
useEffect(() => { useEffect(() => {
setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1); setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
setVisibleStartIndex(0); setVisibleStartIndex(0);
@@ -264,6 +232,5 @@ export function useCommandCompletion(
navigateUp, navigateUp,
navigateDown, navigateDown,
handleAutocomplete, handleAutocomplete,
promptCompletion,
}; };
} }

View File

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

View File

@@ -4,13 +4,13 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { MockedFunction } from 'vitest'; import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react'; import { act } from 'react';
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
import { useGitBranchName } from './useGitBranchName.js'; import { useGitBranchName } from './useGitBranchName.js';
import { fs, vol } from 'memfs'; // For mocking fs import { fs, vol } from 'memfs'; // For mocking fs
import { spawnAsync as mockSpawnAsync } from '@qwen-code/qwen-code-core'; import { isCommandAvailable, execCommand } from '@qwen-code/qwen-code-core';
// Mock @qwen-code/qwen-code-core // Mock @qwen-code/qwen-code-core
vi.mock('@qwen-code/qwen-code-core', async () => { vi.mock('@qwen-code/qwen-code-core', async () => {
@@ -19,7 +19,8 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
>('@qwen-code/qwen-code-core'); >('@qwen-code/qwen-code-core');
return { return {
...original, ...original,
spawnAsync: vi.fn(), execCommand: vi.fn(),
isCommandAvailable: vi.fn(),
}; };
}); });
@@ -47,6 +48,7 @@ describe('useGitBranchName', () => {
[GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/main', [GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/main',
}); });
vi.useFakeTimers(); // Use fake timers for async operations vi.useFakeTimers(); // Use fake timers for async operations
(isCommandAvailable as Mock).mockReturnValue({ available: true });
}); });
afterEach(() => { afterEach(() => {
@@ -55,11 +57,11 @@ describe('useGitBranchName', () => {
}); });
it('should return branch name', async () => { it('should return branch name', async () => {
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockResolvedValue( (execCommand as Mock).mockResolvedValueOnce({
{ stdout: 'main\n',
stdout: 'main\n', stderr: '',
} as { stdout: string; stderr: string }, code: 0,
); });
const { result, rerender } = renderHook(() => useGitBranchName(CWD)); const { result, rerender } = renderHook(() => useGitBranchName(CWD));
await act(async () => { await act(async () => {
@@ -71,9 +73,7 @@ describe('useGitBranchName', () => {
}); });
it('should return undefined if git command fails', async () => { it('should return undefined if git command fails', async () => {
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockRejectedValue( (execCommand as Mock).mockRejectedValue(new Error('Git error'));
new Error('Git error'),
);
const { result, rerender } = renderHook(() => useGitBranchName(CWD)); const { result, rerender } = renderHook(() => useGitBranchName(CWD));
expect(result.current).toBeUndefined(); expect(result.current).toBeUndefined();
@@ -86,16 +86,16 @@ describe('useGitBranchName', () => {
}); });
it('should return short commit hash if branch is HEAD (detached state)', async () => { it('should return short commit hash if branch is HEAD (detached state)', async () => {
( (execCommand as Mock).mockImplementation(
mockSpawnAsync as MockedFunction<typeof mockSpawnAsync> async (_command: string, args?: readonly string[] | null) => {
).mockImplementation(async (command: string, args: string[]) => { if (args?.includes('--abbrev-ref')) {
if (args.includes('--abbrev-ref')) { return { stdout: 'HEAD\n', stderr: '', code: 0 };
return { stdout: 'HEAD\n' } as { stdout: string; stderr: string }; } else if (args?.includes('--short')) {
} else if (args.includes('--short')) { return { stdout: 'a1b2c3d\n', stderr: '', code: 0 };
return { stdout: 'a1b2c3d\n' } as { stdout: string; stderr: string }; }
} return { stdout: '', stderr: '', code: 0 };
return { stdout: '' } as { stdout: string; stderr: string }; },
}); );
const { result, rerender } = renderHook(() => useGitBranchName(CWD)); const { result, rerender } = renderHook(() => useGitBranchName(CWD));
await act(async () => { await act(async () => {
@@ -106,16 +106,16 @@ describe('useGitBranchName', () => {
}); });
it('should return undefined if branch is HEAD and getting commit hash fails', async () => { it('should return undefined if branch is HEAD and getting commit hash fails', async () => {
( (execCommand as Mock).mockImplementation(
mockSpawnAsync as MockedFunction<typeof mockSpawnAsync> async (_command: string, args?: readonly string[] | null) => {
).mockImplementation(async (command: string, args: string[]) => { if (args?.includes('--abbrev-ref')) {
if (args.includes('--abbrev-ref')) { return { stdout: 'HEAD\n', stderr: '', code: 0 };
return { stdout: 'HEAD\n' } as { stdout: string; stderr: string }; } else if (args?.includes('--short')) {
} else if (args.includes('--short')) { throw new Error('Git error');
throw new Error('Git error'); }
} return { stdout: '', stderr: '', code: 0 };
return { stdout: '' } as { stdout: string; stderr: string }; },
}); );
const { result, rerender } = renderHook(() => useGitBranchName(CWD)); const { result, rerender } = renderHook(() => useGitBranchName(CWD));
await act(async () => { await act(async () => {
@@ -127,14 +127,16 @@ describe('useGitBranchName', () => {
it('should update branch name when .git/HEAD changes', async ({ skip }) => { it('should update branch name when .git/HEAD changes', async ({ skip }) => {
skip(); // TODO: fix skip(); // TODO: fix
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>) (execCommand as Mock)
.mockResolvedValueOnce({ stdout: 'main\n' } as { .mockResolvedValueOnce({
stdout: string; stdout: 'main\n',
stderr: string; stderr: '',
code: 0,
}) })
.mockResolvedValueOnce({ stdout: 'develop\n' } as { .mockResolvedValueOnce({
stdout: string; stdout: 'develop\n',
stderr: string; stderr: '',
code: 0,
}); });
const { result, rerender } = renderHook(() => useGitBranchName(CWD)); const { result, rerender } = renderHook(() => useGitBranchName(CWD));
@@ -162,11 +164,11 @@ describe('useGitBranchName', () => {
// Remove .git/logs/HEAD to cause an error in fs.watch setup // Remove .git/logs/HEAD to cause an error in fs.watch setup
vol.unlinkSync(GIT_LOGS_HEAD_PATH); vol.unlinkSync(GIT_LOGS_HEAD_PATH);
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockResolvedValue( (execCommand as Mock).mockResolvedValue({
{ stdout: 'main\n',
stdout: 'main\n', stderr: '',
} as { stdout: string; stderr: string }, code: 0,
); });
const { result, rerender } = renderHook(() => useGitBranchName(CWD)); const { result, rerender } = renderHook(() => useGitBranchName(CWD));
@@ -177,11 +179,11 @@ describe('useGitBranchName', () => {
expect(result.current).toBe('main'); // Branch name should still be fetched initially expect(result.current).toBe('main'); // Branch name should still be fetched initially
( (execCommand as Mock).mockResolvedValueOnce({
mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>
).mockResolvedValueOnce({
stdout: 'develop\n', stdout: 'develop\n',
} as { stdout: string; stderr: string }); stderr: '',
code: 0,
});
// This write would trigger the watcher if it was set up // This write would trigger the watcher if it was set up
// but since it failed, the branch name should not update // but since it failed, the branch name should not update
@@ -207,11 +209,11 @@ describe('useGitBranchName', () => {
close: closeMock, close: closeMock,
} as unknown as ReturnType<typeof fs.watch>); } as unknown as ReturnType<typeof fs.watch>);
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockResolvedValue( (execCommand as Mock).mockResolvedValue({
{ stdout: 'main\n',
stdout: 'main\n', stderr: '',
} as { stdout: string; stderr: string }, code: 0,
); });
const { unmount, rerender } = renderHook(() => useGitBranchName(CWD)); const { unmount, rerender } = renderHook(() => useGitBranchName(CWD));

View File

@@ -5,7 +5,7 @@
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { spawnAsync } from '@qwen-code/qwen-code-core'; import { isCommandAvailable, execCommand } from '@qwen-code/qwen-code-core';
import fs from 'node:fs'; import fs from 'node:fs';
import fsPromises from 'node:fs/promises'; import fsPromises from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -15,7 +15,11 @@ export function useGitBranchName(cwd: string): string | undefined {
const fetchBranchName = useCallback(async () => { const fetchBranchName = useCallback(async () => {
try { try {
const { stdout } = await spawnAsync( if (!isCommandAvailable('git').available) {
return;
}
const { stdout } = await execCommand(
'git', 'git',
['rev-parse', '--abbrev-ref', 'HEAD'], ['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd }, { cwd },
@@ -24,7 +28,7 @@ export function useGitBranchName(cwd: string): string | undefined {
if (branch && branch !== 'HEAD') { if (branch && branch !== 'HEAD') {
setBranchName(branch); setBranchName(branch);
} else { } else {
const { stdout: hashStdout } = await spawnAsync( const { stdout: hashStdout } = await execCommand(
'git', 'git',
['rev-parse', '--short', 'HEAD'], ['rev-parse', '--short', 'HEAD'],
{ cwd }, { cwd },

View File

@@ -0,0 +1,47 @@
/**
* @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

@@ -1,254 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import type { Config } from '@qwen-code/qwen-code-core';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
getResponseText,
} from '@qwen-code/qwen-code-core';
import type { Content, GenerateContentConfig } from '@google/genai';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import { isSlashCommand } from '../utils/commandUtils.js';
export const PROMPT_COMPLETION_MIN_LENGTH = 5;
export const PROMPT_COMPLETION_DEBOUNCE_MS = 250;
export interface PromptCompletion {
text: string;
isLoading: boolean;
isActive: boolean;
accept: () => void;
clear: () => void;
markSelected: (selectedText: string) => void;
}
export interface UsePromptCompletionOptions {
buffer: TextBuffer;
config?: Config;
enabled: boolean;
}
export function usePromptCompletion({
buffer,
config,
enabled,
}: UsePromptCompletionOptions): PromptCompletion {
const [ghostText, setGhostText] = useState<string>('');
const [isLoadingGhostText, setIsLoadingGhostText] = useState<boolean>(false);
const abortControllerRef = useRef<AbortController | null>(null);
const [justSelectedSuggestion, setJustSelectedSuggestion] =
useState<boolean>(false);
const lastSelectedTextRef = useRef<string>('');
const lastRequestedTextRef = useRef<string>('');
const isPromptCompletionEnabled =
enabled && (config?.getEnablePromptCompletion() ?? false);
const clearGhostText = useCallback(() => {
setGhostText('');
setIsLoadingGhostText(false);
}, []);
const acceptGhostText = useCallback(() => {
if (ghostText && ghostText.length > buffer.text.length) {
buffer.setText(ghostText);
setGhostText('');
setJustSelectedSuggestion(true);
lastSelectedTextRef.current = ghostText;
}
}, [ghostText, buffer]);
const markSuggestionSelected = useCallback((selectedText: string) => {
setJustSelectedSuggestion(true);
lastSelectedTextRef.current = selectedText;
}, []);
const generatePromptSuggestions = useCallback(async () => {
const trimmedText = buffer.text.trim();
const geminiClient = config?.getGeminiClient();
if (trimmedText === lastRequestedTextRef.current) {
return;
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (
trimmedText.length < PROMPT_COMPLETION_MIN_LENGTH ||
!geminiClient ||
isSlashCommand(trimmedText) ||
trimmedText.includes('@') ||
!isPromptCompletionEnabled
) {
clearGhostText();
lastRequestedTextRef.current = '';
return;
}
lastRequestedTextRef.current = trimmedText;
setIsLoadingGhostText(true);
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
try {
const contents: Content[] = [
{
role: 'user',
parts: [
{
text: `You are a professional prompt engineering assistant. Complete the user's partial prompt with expert precision and clarity. User's input: "${trimmedText}" Continue this prompt by adding specific, actionable details that align with the user's intent. Focus on: clear, precise language; structured requirements; professional terminology; measurable outcomes. Length Guidelines: Keep suggestions concise (ideally 10-20 characters); prioritize brevity while maintaining clarity; use essential keywords only; avoid redundant phrases. Start your response with the exact user text ("${trimmedText}") followed by your completion. Provide practical, implementation-focused suggestions rather than creative interpretations. Format: Plain text only. Single completion. Match the user's language. Emphasize conciseness over elaboration.`,
},
],
},
];
const generationConfig: GenerateContentConfig = {
temperature: 0.3,
maxOutputTokens: 16000,
thinkingConfig: {
thinkingBudget: 0,
},
};
const response = await geminiClient.generateContent(
contents,
generationConfig,
signal,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
);
if (signal.aborted) {
return;
}
if (response) {
const responseText = getResponseText(response);
if (responseText) {
const suggestionText = responseText.trim();
if (
suggestionText.length > 0 &&
suggestionText.startsWith(trimmedText)
) {
setGhostText(suggestionText);
} else {
clearGhostText();
}
}
}
} catch (error) {
if (
!(
signal.aborted ||
(error instanceof Error && error.name === 'AbortError')
)
) {
console.error('prompt completion error:', error);
// Clear the last requested text to allow retry only on real errors
lastRequestedTextRef.current = '';
}
clearGhostText();
} finally {
if (!signal.aborted) {
setIsLoadingGhostText(false);
}
}
}, [buffer.text, config, clearGhostText, isPromptCompletionEnabled]);
const isCursorAtEnd = useCallback(() => {
const [cursorRow, cursorCol] = buffer.cursor;
const totalLines = buffer.lines.length;
if (cursorRow !== totalLines - 1) {
return false;
}
const lastLine = buffer.lines[cursorRow] || '';
return cursorCol === lastLine.length;
}, [buffer.cursor, buffer.lines]);
const handlePromptCompletion = useCallback(() => {
if (!isCursorAtEnd()) {
clearGhostText();
return;
}
const trimmedText = buffer.text.trim();
if (justSelectedSuggestion && trimmedText === lastSelectedTextRef.current) {
return;
}
if (trimmedText !== lastSelectedTextRef.current) {
setJustSelectedSuggestion(false);
lastSelectedTextRef.current = '';
}
generatePromptSuggestions();
}, [
buffer.text,
generatePromptSuggestions,
justSelectedSuggestion,
isCursorAtEnd,
clearGhostText,
]);
// Debounce prompt completion
useEffect(() => {
const timeoutId = setTimeout(
handlePromptCompletion,
PROMPT_COMPLETION_DEBOUNCE_MS,
);
return () => clearTimeout(timeoutId);
}, [buffer.text, buffer.cursor, handlePromptCompletion]);
// Ghost text validation - clear if it doesn't match current text or cursor not at end
useEffect(() => {
const currentText = buffer.text.trim();
if (ghostText && !isCursorAtEnd()) {
clearGhostText();
return;
}
if (
ghostText &&
currentText.length > 0 &&
!ghostText.startsWith(currentText)
) {
clearGhostText();
}
}, [buffer.text, buffer.cursor, ghostText, clearGhostText, isCursorAtEnd]);
// Cleanup on unmount
useEffect(() => () => abortControllerRef.current?.abort(), []);
const isActive = useMemo(() => {
if (!isPromptCompletionEnabled) return false;
if (!isCursorAtEnd()) return false;
const trimmedText = buffer.text.trim();
return (
trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH &&
!isSlashCommand(trimmedText) &&
!trimmedText.includes('@')
);
}, [buffer.text, isPromptCompletionEnabled, isCursorAtEnd]);
return {
text: ghostText,
isLoading: isLoadingGhostText,
isActive,
accept: acceptGhostText,
clear: clearGhostText,
markSelected: markSuggestionSelected,
};
}

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import * as path from 'node:path'; import * as path from 'node:path';
import { spawnAsync } from '@qwen-code/qwen-code-core'; import { execCommand } from '@qwen-code/qwen-code-core';
/** /**
* Checks if the system clipboard contains an image (macOS only for now) * Checks if the system clipboard contains an image (macOS only for now)
@@ -19,7 +19,7 @@ export async function clipboardHasImage(): Promise<boolean> {
try { try {
// Use osascript to check clipboard type // Use osascript to check clipboard type
const { stdout } = await spawnAsync('osascript', ['-e', 'clipboard info']); const { stdout } = await execCommand('osascript', ['-e', 'clipboard info']);
const imageRegex = const imageRegex =
/«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/; /«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/;
return imageRegex.test(stdout); return imageRegex.test(stdout);
@@ -80,7 +80,7 @@ export async function saveClipboardImage(
end try end try
`; `;
const { stdout } = await spawnAsync('osascript', ['-e', script]); const { stdout } = await execCommand('osascript', ['-e', script]);
if (stdout.trim() === 'success') { if (stdout.trim() === 'success') {
// Verify the file was created and has content // Verify the file was created and has content

View File

@@ -13,6 +13,7 @@ import {
isSlashCommand, isSlashCommand,
copyToClipboard, copyToClipboard,
getUrlOpenCommand, getUrlOpenCommand,
CodePage,
} from './commandUtils.js'; } from './commandUtils.js';
// Mock child_process // Mock child_process
@@ -188,7 +189,10 @@ describe('commandUtils', () => {
await copyToClipboard(testText); await copyToClipboard(testText);
expect(mockSpawn).toHaveBeenCalledWith('clip', []); expect(mockSpawn).toHaveBeenCalledWith('cmd', [
'/c',
`chcp ${CodePage.UTF8} >nul && clip`,
]);
expect(mockChild.stdin.write).toHaveBeenCalledWith(testText); expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
expect(mockChild.stdin.end).toHaveBeenCalled(); expect(mockChild.stdin.end).toHaveBeenCalled();
}); });

View File

@@ -7,6 +7,23 @@
import type { SpawnOptions } from 'node:child_process'; import type { SpawnOptions } from 'node:child_process';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
/**
* Common Windows console code pages (CP) used for encoding conversions.
*
* @remarks
* - `UTF8` (65001): Unicode (UTF-8) — recommended for cross-language scripts.
* - `GBK` (936): Simplified Chinese — default on most Chinese Windows systems.
* - `BIG5` (950): Traditional Chinese.
* - `LATIN1` (1252): Western European — default on many Western systems.
*/
export const CodePage = {
UTF8: 65001,
GBK: 936,
BIG5: 950,
LATIN1: 1252,
} as const;
export type CodePage = (typeof CodePage)[keyof typeof CodePage];
/** /**
* Checks if a query string potentially represents an '@' command. * Checks if a query string potentially represents an '@' command.
* It triggers if the query starts with '@' or contains '@' preceded by whitespace * It triggers if the query starts with '@' or contains '@' preceded by whitespace
@@ -80,7 +97,7 @@ export const copyToClipboard = async (text: string): Promise<void> => {
switch (process.platform) { switch (process.platform) {
case 'win32': case 'win32':
return run('clip', []); return run('cmd', ['/c', `chcp ${CodePage.UTF8} >nul && clip`]);
case 'darwin': case 'darwin':
return run('pbcopy', []); return run('pbcopy', []);
case 'linux': case 'linux':

View File

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

View File

@@ -0,0 +1,72 @@
/**
* @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

@@ -0,0 +1,43 @@
/**
* @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,7 +14,11 @@ import { settingExistsInScope } from './settingsUtils.js';
export const SCOPE_LABELS = { export const SCOPE_LABELS = {
[SettingScope.User]: 'User Settings', [SettingScope.User]: 'User Settings',
[SettingScope.Workspace]: 'Workspace Settings', [SettingScope.Workspace]: 'Workspace Settings',
[SettingScope.System]: 'System 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',
} as const; } as const;
/** /**
@@ -27,7 +31,7 @@ export function getScopeItems() {
label: SCOPE_LABELS[SettingScope.Workspace], label: SCOPE_LABELS[SettingScope.Workspace],
value: SettingScope.Workspace, value: SettingScope.Workspace,
}, },
{ label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System }, // { label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System },
]; ];
} }

View File

@@ -67,11 +67,15 @@ const ripgrepAvailabilityCheck: WarningCheck = {
return null; return null;
} }
const isAvailable = await canUseRipgrep(options.useBuiltinRipgrep); try {
if (!isAvailable) { const isAvailable = await canUseRipgrep(options.useBuiltinRipgrep);
return 'Ripgrep not available: Please install ripgrep globally to enable faster file content search. Falling back to built-in grep.'; if (!isAvailable) {
return 'Ripgrep not available: Please install ripgrep globally to enable faster file content search. Falling back to built-in grep.';
}
return null;
} catch (error) {
return `Ripgrep not available: ${error instanceof Error ? error.message : 'Unknown error'}. Falling back to built-in grep.`;
} }
return null;
}, },
}; };

View File

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

View File

@@ -128,6 +128,14 @@ export type AgentRequest = z.infer<typeof agentRequestSchema>;
export type AgentNotification = z.infer<typeof agentNotificationSchema>; 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({ export const writeTextFileRequestSchema = z.object({
content: z.string(), content: z.string(),
path: z.string(), path: z.string(),
@@ -386,6 +394,21 @@ export const promptRequestSchema = z.object({
sessionId: z.string(), 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([ export const sessionUpdateSchema = z.union([
z.object({ z.object({
content: contentBlockSchema, content: contentBlockSchema,
@@ -423,6 +446,7 @@ export const sessionUpdateSchema = z.union([
entries: z.array(planEntrySchema), entries: z.array(planEntrySchema),
sessionUpdate: z.literal('plan'), sessionUpdate: z.literal('plan'),
}), }),
availableCommandsUpdateSchema,
]); ]);
export const agentResponseSchema = z.union([ export const agentResponseSchema = z.union([

View File

@@ -12,6 +12,12 @@ import type {
GeminiChat, GeminiChat,
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
ToolResult, ToolResult,
SubAgentEventEmitter,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
AnyDeclarativeTool,
AnyToolInvocation,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { import {
AuthType, AuthType,
@@ -25,9 +31,15 @@ import {
MCPServerConfig, MCPServerConfig,
ToolConfirmationOutcome, ToolConfirmationOutcome,
logToolCall, logToolCall,
logUserPrompt,
getErrorStatus, getErrorStatus,
isWithinRoot, isWithinRoot,
isNodeError, isNodeError,
SubAgentEventType,
TaskTool,
Kind,
TodoWriteTool,
UserPromptEvent,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import * as acp from './acp.js'; import * as acp from './acp.js';
import { AcpFileSystemService } from './fileSystemService.js'; import { AcpFileSystemService } from './fileSystemService.js';
@@ -43,6 +55,26 @@ import { ExtensionStorage, type Extension } from '../config/extension.js';
import type { CliArgs } from '../config/config.js'; import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js'; import { loadCliConfig } from '../config/config.js';
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.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. * Resolves the model to use based on the current configuration.
@@ -141,7 +173,7 @@ class GeminiAgent {
cwd, cwd,
mcpServers, mcpServers,
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> { }: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
const sessionId = randomUUID(); const sessionId = this.config.getSessionId() || randomUUID();
const config = await this.newSessionConfig(sessionId, cwd, mcpServers); const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
let isAuthenticated = false; let isAuthenticated = false;
@@ -172,9 +204,20 @@ class GeminiAgent {
const geminiClient = config.getGeminiClient(); const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat(); const chat = await geminiClient.startChat();
const session = new Session(sessionId, chat, config, this.client); const session = new Session(
sessionId,
chat,
config,
this.client,
this.settings,
);
this.sessions.set(sessionId, session); this.sessions.set(sessionId, session);
// Send available commands update as the first session update
setTimeout(async () => {
await session.sendAvailableCommandsUpdate();
}, 0);
return { return {
sessionId, sessionId,
}; };
@@ -232,12 +275,14 @@ class GeminiAgent {
class Session { class Session {
private pendingPrompt: AbortController | null = null; private pendingPrompt: AbortController | null = null;
private turn: number = 0;
constructor( constructor(
private readonly id: string, private readonly id: string,
private readonly chat: GeminiChat, private readonly chat: GeminiChat,
private readonly config: Config, private readonly config: Config,
private readonly client: acp.Client, private readonly client: acp.Client,
private readonly settings: LoadedSettings,
) {} ) {}
async cancelPendingPrompt(): Promise<void> { async cancelPendingPrompt(): Promise<void> {
@@ -254,10 +299,57 @@ class Session {
const pendingSend = new AbortController(); const pendingSend = new AbortController();
this.pendingPrompt = pendingSend; this.pendingPrompt = pendingSend;
const promptId = Math.random().toString(16).slice(2); // Increment turn counter for each user prompt
const chat = this.chat; this.turn += 1;
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); 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);
}
let nextMessage: Content | null = { role: 'user', parts }; let nextMessage: Content | null = { role: 'user', parts };
@@ -351,6 +443,37 @@ class Session {
await this.client.sessionUpdate(params); 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( private async runTool(
abortSignal: AbortSignal, abortSignal: AbortSignal,
promptId: string, promptId: string,
@@ -403,9 +526,34 @@ 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 { try {
const invocation = tool.build(args); 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 = const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal); await invocation.shouldConfirmExecute(abortSignal);
@@ -460,7 +608,8 @@ class Session {
throw new Error(`Unexpected: ${resultOutcome}`); throw new Error(`Unexpected: ${resultOutcome}`);
} }
} }
} else { } else if (!isTodoWriteTool) {
// Skip tool_call event for TodoWriteTool
await this.sendUpdate({ await this.sendUpdate({
sessionUpdate: 'tool_call', sessionUpdate: 'tool_call',
toolCallId: callId, toolCallId: callId,
@@ -473,14 +622,61 @@ class Session {
} }
const toolResult: ToolResult = await invocation.execute(abortSignal); const toolResult: ToolResult = await invocation.execute(abortSignal);
const content = toToolCallContent(toolResult);
await this.sendUpdate({ // Clean up event listeners
sessionUpdate: 'tool_call_update', subAgentToolEventListeners.forEach((cleanup) => cleanup());
toolCallId: callId,
status: 'completed', // Handle TodoWriteTool: extract todos and send plan update
content: content ? [content] : [], 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] : [],
});
}
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
logToolCall(this.config, { logToolCall(this.config, {
@@ -500,6 +696,9 @@ class Session {
return convertToFunctionResponse(fc.name, callId, toolResult.llmContent); return convertToFunctionResponse(fc.name, callId, toolResult.llmContent);
} catch (e) { } catch (e) {
// Ensure cleanup on error
subAgentToolEventListeners.forEach((cleanup) => cleanup());
const error = e instanceof Error ? e : new Error(String(e)); const error = e instanceof Error ? e : new Error(String(e));
await this.sendUpdate({ await this.sendUpdate({
@@ -515,6 +714,300 @@ 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( async #resolvePrompt(
message: acp.ContentBlock[], message: acp.ContentBlock[],
abortSignal: AbortSignal, abortSignal: AbortSignal,
@@ -859,6 +1352,27 @@ 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 { function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
if (toolResult.error?.message) { if (toolResult.error?.message) {
throw new Error(toolResult.error.message); throw new Error(toolResult.error.message);
@@ -870,26 +1384,6 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
type: 'content', type: 'content',
content: { type: 'text', text: toolResult.returnDisplay }, 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 ( } else if (
'type' in toolResult.returnDisplay && 'type' in toolResult.returnDisplay &&
toolResult.returnDisplay.type === 'plan_summary' toolResult.returnDisplay.type === 'plan_summary'

View File

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

View File

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

View File

@@ -20,66 +20,81 @@ const vendorDir = path.join(packageRoot, 'vendor', 'ripgrep');
/** /**
* Remove quarantine attribute and set executable permissions on macOS/Linux * Remove quarantine attribute and set executable permissions on macOS/Linux
* This script never throws errors to avoid blocking npm workflows.
*/ */
function setupRipgrepBinaries() { function setupRipgrepBinaries() {
if (!fs.existsSync(vendorDir)) {
console.log('Vendor directory not found, skipping ripgrep setup');
return;
}
const platform = process.platform;
const arch = process.arch;
// Determine the binary directory based on platform and architecture
let binaryDir;
if (platform === 'darwin' || platform === 'linux') {
const archStr = arch === 'x64' || arch === 'arm64' ? arch : null;
if (archStr) {
binaryDir = path.join(vendorDir, `${archStr}-${platform}`);
}
} else if (platform === 'win32') {
// Windows doesn't need these fixes
return;
}
if (!binaryDir || !fs.existsSync(binaryDir)) {
console.log(
`Binary directory not found for ${platform}-${arch}, skipping ripgrep setup`,
);
return;
}
const rgBinary = path.join(binaryDir, 'rg');
if (!fs.existsSync(rgBinary)) {
console.log(`Ripgrep binary not found at ${rgBinary}`);
return;
}
try { try {
// Set executable permissions if (!fs.existsSync(vendorDir)) {
fs.chmodSync(rgBinary, 0o755); console.log(' Vendor directory not found, skipping ripgrep setup');
console.log(`✓ Set executable permissions on ${rgBinary}`); return;
}
// On macOS, remove quarantine attribute const platform = process.platform;
if (platform === 'darwin') { const arch = process.arch;
try {
execSync(`xattr -d com.apple.quarantine "${rgBinary}"`, { // Determine the binary directory based on platform and architecture
stdio: 'pipe', let binaryDir;
}); if (platform === 'darwin' || platform === 'linux') {
console.log(`✓ Removed quarantine attribute from ${rgBinary}`); const archStr = arch === 'x64' || arch === 'arm64' ? arch : null;
} catch (error) { if (archStr) {
// Quarantine attribute might not exist, which is fine binaryDir = path.join(vendorDir, `${archStr}-${platform}`);
if (error.message && !error.message.includes('No such xattr')) { }
console.warn( } else if (platform === 'win32') {
`Warning: Could not remove quarantine attribute: ${error.message}`, // Windows doesn't need these fixes
); console.log(' Windows detected, skipping ripgrep setup');
return;
}
if (!binaryDir || !fs.existsSync(binaryDir)) {
console.log(
` Binary directory not found for ${platform}-${arch}, skipping ripgrep setup`,
);
return;
}
const rgBinary = path.join(binaryDir, 'rg');
if (!fs.existsSync(rgBinary)) {
console.log(` Ripgrep binary not found at ${rgBinary}, skipping setup`);
return;
}
try {
// Set executable permissions
fs.chmodSync(rgBinary, 0o755);
console.log(`✓ Set executable permissions on ${rgBinary}`);
// On macOS, remove quarantine attribute
if (platform === 'darwin') {
try {
execSync(`xattr -d com.apple.quarantine "${rgBinary}"`, {
stdio: 'pipe',
});
console.log(`✓ Removed quarantine attribute from ${rgBinary}`);
} catch {
// Quarantine attribute might not exist, which is fine
console.log(' Quarantine attribute not present or already removed');
} }
} }
} catch (error) {
console.log(
`⚠ Could not complete ripgrep setup: ${error.message || 'Unknown error'}`,
);
console.log(' This is not critical - ripgrep may still work correctly');
} }
} catch (error) { } catch (error) {
console.error(`Error setting up ripgrep binary: ${error.message}`); console.log(
`⚠ Ripgrep setup encountered an issue: ${error.message || 'Unknown error'}`,
);
console.log(' Continuing anyway - this should not affect functionality');
} }
} }
setupRipgrepBinaries(); // Wrap the entire execution to ensure no errors escape to npm
try {
setupRipgrepBinaries();
} catch {
// Last resort catch - never let errors block npm
console.log('⚠ Postinstall script encountered an unexpected error');
console.log(' This will not affect the installation');
}

View File

@@ -45,6 +45,15 @@ import { logRipgrepFallback } from '../telemetry/loggers.js';
import { RipgrepFallbackEvent } from '../telemetry/types.js'; import { RipgrepFallbackEvent } from '../telemetry/types.js';
import { ToolRegistry } from '../tools/tool-registry.js'; import { ToolRegistry } from '../tools/tool-registry.js';
function createToolMock(toolName: string) {
const ToolMock = vi.fn();
Object.defineProperty(ToolMock, 'Name', {
value: toolName,
writable: true,
});
return ToolMock;
}
vi.mock('fs', async (importOriginal) => { vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>(); const actual = await importOriginal<typeof import('fs')>();
return { return {
@@ -73,23 +82,41 @@ vi.mock('../utils/memoryDiscovery.js', () => ({
})); }));
// Mock individual tools if their constructors are complex or have side effects // Mock individual tools if their constructors are complex or have side effects
vi.mock('../tools/ls'); vi.mock('../tools/ls', () => ({
vi.mock('../tools/read-file'); LSTool: createToolMock('list_directory'),
vi.mock('../tools/grep.js'); }));
vi.mock('../tools/read-file', () => ({
ReadFileTool: createToolMock('read_file'),
}));
vi.mock('../tools/grep.js', () => ({
GrepTool: createToolMock('grep_search'),
}));
vi.mock('../tools/ripGrep.js', () => ({ vi.mock('../tools/ripGrep.js', () => ({
RipGrepTool: class MockRipGrepTool {}, RipGrepTool: createToolMock('grep_search'),
})); }));
vi.mock('../utils/ripgrepUtils.js', () => ({ vi.mock('../utils/ripgrepUtils.js', () => ({
canUseRipgrep: vi.fn(), canUseRipgrep: vi.fn(),
})); }));
vi.mock('../tools/glob'); vi.mock('../tools/glob', () => ({
vi.mock('../tools/edit'); GlobTool: createToolMock('glob'),
vi.mock('../tools/shell'); }));
vi.mock('../tools/write-file'); vi.mock('../tools/edit', () => ({
vi.mock('../tools/web-fetch'); EditTool: createToolMock('edit'),
vi.mock('../tools/read-many-files'); }));
vi.mock('../tools/shell', () => ({
ShellTool: createToolMock('run_shell_command'),
}));
vi.mock('../tools/write-file', () => ({
WriteFileTool: createToolMock('write_file'),
}));
vi.mock('../tools/web-fetch', () => ({
WebFetchTool: createToolMock('web_fetch'),
}));
vi.mock('../tools/read-many-files', () => ({
ReadManyFilesTool: createToolMock('read_many_files'),
}));
vi.mock('../tools/memoryTool', () => ({ vi.mock('../tools/memoryTool', () => ({
MemoryTool: vi.fn(), MemoryTool: createToolMock('save_memory'),
setGeminiMdFilename: vi.fn(), setGeminiMdFilename: vi.fn(),
getCurrentGeminiMdFilename: vi.fn(() => 'QWEN.md'), // Mock the original filename getCurrentGeminiMdFilename: vi.fn(() => 'QWEN.md'), // Mock the original filename
DEFAULT_CONTEXT_FILENAME: 'QWEN.md', DEFAULT_CONTEXT_FILENAME: 'QWEN.md',
@@ -621,7 +648,7 @@ describe('Server Config (config.ts)', () => {
it('should register a tool if coreTools contains an argument-specific pattern', async () => { it('should register a tool if coreTools contains an argument-specific pattern', async () => {
const params: ConfigParameters = { const params: ConfigParameters = {
...baseParams, ...baseParams,
coreTools: ['ShellTool(git status)'], coreTools: ['Shell(git status)'], // Use display name instead of class name
}; };
const config = new Config(params); const config = new Config(params);
await config.initialize(); await config.initialize();
@@ -646,6 +673,89 @@ describe('Server Config (config.ts)', () => {
expect(wasReadFileToolRegistered).toBe(false); expect(wasReadFileToolRegistered).toBe(false);
}); });
it('should register a tool if coreTools contains the displayName', async () => {
const params: ConfigParameters = {
...baseParams,
coreTools: ['Shell'],
};
const config = new Config(params);
await config.initialize();
const registerToolMock = (
(await vi.importMock('../tools/tool-registry')) as {
ToolRegistry: { prototype: { registerTool: Mock } };
}
).ToolRegistry.prototype.registerTool;
const wasShellToolRegistered = (registerToolMock as Mock).mock.calls.some(
(call) => call[0] instanceof vi.mocked(ShellTool),
);
expect(wasShellToolRegistered).toBe(true);
});
it('should register a tool if coreTools contains the displayName with argument-specific pattern', async () => {
const params: ConfigParameters = {
...baseParams,
coreTools: ['Shell(git status)'],
};
const config = new Config(params);
await config.initialize();
const registerToolMock = (
(await vi.importMock('../tools/tool-registry')) as {
ToolRegistry: { prototype: { registerTool: Mock } };
}
).ToolRegistry.prototype.registerTool;
const wasShellToolRegistered = (registerToolMock as Mock).mock.calls.some(
(call) => call[0] instanceof vi.mocked(ShellTool),
);
expect(wasShellToolRegistered).toBe(true);
});
it('should register a tool if coreTools contains a legacy tool name alias', async () => {
const params: ConfigParameters = {
...baseParams,
useRipgrep: false,
coreTools: ['search_file_content'],
};
const config = new Config(params);
await config.initialize();
const registerToolMock = (
(await vi.importMock('../tools/tool-registry')) as {
ToolRegistry: { prototype: { registerTool: Mock } };
}
).ToolRegistry.prototype.registerTool;
const wasGrepToolRegistered = (registerToolMock as Mock).mock.calls.some(
(call) => call[0] instanceof vi.mocked(GrepTool),
);
expect(wasGrepToolRegistered).toBe(true);
});
it('should not register a tool if excludeTools contains a legacy display name alias', async () => {
const params: ConfigParameters = {
...baseParams,
useRipgrep: false,
coreTools: undefined,
excludeTools: ['SearchFiles'],
};
const config = new Config(params);
await config.initialize();
const registerToolMock = (
(await vi.importMock('../tools/tool-registry')) as {
ToolRegistry: { prototype: { registerTool: Mock } };
}
).ToolRegistry.prototype.registerTool;
const wasGrepToolRegistered = (registerToolMock as Mock).mock.calls.some(
(call) => call[0] instanceof vi.mocked(GrepTool),
);
expect(wasGrepToolRegistered).toBe(false);
});
describe('with minified tool class names', () => { describe('with minified tool class names', () => {
beforeEach(() => { beforeEach(() => {
Object.defineProperty( Object.defineProperty(
@@ -671,7 +781,27 @@ describe('Server Config (config.ts)', () => {
it('should register a tool if coreTools contains the non-minified class name', async () => { it('should register a tool if coreTools contains the non-minified class name', async () => {
const params: ConfigParameters = { const params: ConfigParameters = {
...baseParams, ...baseParams,
coreTools: ['ShellTool'], coreTools: ['Shell'], // Use display name instead of class name
};
const config = new Config(params);
await config.initialize();
const registerToolMock = (
(await vi.importMock('../tools/tool-registry')) as {
ToolRegistry: { prototype: { registerTool: Mock } };
}
).ToolRegistry.prototype.registerTool;
const wasShellToolRegistered = (
registerToolMock as Mock
).mock.calls.some((call) => call[0] instanceof vi.mocked(ShellTool));
expect(wasShellToolRegistered).toBe(true);
});
it('should register a tool if coreTools contains the displayName', async () => {
const params: ConfigParameters = {
...baseParams,
coreTools: ['Shell'],
}; };
const config = new Config(params); const config = new Config(params);
await config.initialize(); await config.initialize();
@@ -692,7 +822,28 @@ describe('Server Config (config.ts)', () => {
const params: ConfigParameters = { const params: ConfigParameters = {
...baseParams, ...baseParams,
coreTools: undefined, // all tools enabled by default coreTools: undefined, // all tools enabled by default
excludeTools: ['ShellTool'], excludeTools: ['Shell'], // Use display name instead of class name
};
const config = new Config(params);
await config.initialize();
const registerToolMock = (
(await vi.importMock('../tools/tool-registry')) as {
ToolRegistry: { prototype: { registerTool: Mock } };
}
).ToolRegistry.prototype.registerTool;
const wasShellToolRegistered = (
registerToolMock as Mock
).mock.calls.some((call) => call[0] instanceof vi.mocked(ShellTool));
expect(wasShellToolRegistered).toBe(false);
});
it('should not register a tool if excludeTools contains the displayName', async () => {
const params: ConfigParameters = {
...baseParams,
coreTools: undefined, // all tools enabled by default
excludeTools: ['Shell'],
}; };
const config = new Config(params); const config = new Config(params);
await config.initialize(); await config.initialize();
@@ -712,7 +863,27 @@ describe('Server Config (config.ts)', () => {
it('should register a tool if coreTools contains an argument-specific pattern with the non-minified class name', async () => { it('should register a tool if coreTools contains an argument-specific pattern with the non-minified class name', async () => {
const params: ConfigParameters = { const params: ConfigParameters = {
...baseParams, ...baseParams,
coreTools: ['ShellTool(git status)'], coreTools: ['Shell(git status)'], // Use display name instead of class name
};
const config = new Config(params);
await config.initialize();
const registerToolMock = (
(await vi.importMock('../tools/tool-registry')) as {
ToolRegistry: { prototype: { registerTool: Mock } };
}
).ToolRegistry.prototype.registerTool;
const wasShellToolRegistered = (
registerToolMock as Mock
).mock.calls.some((call) => call[0] instanceof vi.mocked(ShellTool));
expect(wasShellToolRegistered).toBe(true);
});
it('should register a tool if coreTools contains an argument-specific pattern with the displayName', async () => {
const params: ConfigParameters = {
...baseParams,
coreTools: ['Shell(git status)'],
}; };
const config = new Config(params); const config = new Config(params);
await config.initialize(); await config.initialize();
@@ -914,7 +1085,7 @@ describe('setApprovalMode with folder trust', () => {
expect.any(RipgrepFallbackEvent), expect.any(RipgrepFallbackEvent),
); );
const event = (logRipgrepFallback as Mock).mock.calls[0][1]; const event = (logRipgrepFallback as Mock).mock.calls[0][1];
expect(event.error).toContain('Ripgrep is not available'); expect(event.error).toContain('ripgrep is not available');
}); });
it('should fall back to GrepTool and log error when useRipgrep is true and builtin ripgrep is not available', async () => { it('should fall back to GrepTool and log error when useRipgrep is true and builtin ripgrep is not available', async () => {
@@ -938,7 +1109,7 @@ describe('setApprovalMode with folder trust', () => {
expect.any(RipgrepFallbackEvent), expect.any(RipgrepFallbackEvent),
); );
const event = (logRipgrepFallback as Mock).mock.calls[0][1]; const event = (logRipgrepFallback as Mock).mock.calls[0][1];
expect(event.error).toContain('Ripgrep is not available'); expect(event.error).toContain('ripgrep is not available');
}); });
it('should fall back to GrepTool and log error when canUseRipgrep throws an error', async () => { it('should fall back to GrepTool and log error when canUseRipgrep throws an error', async () => {
@@ -962,7 +1133,7 @@ describe('setApprovalMode with folder trust', () => {
expect.any(RipgrepFallbackEvent), expect.any(RipgrepFallbackEvent),
); );
const event = (logRipgrepFallback as Mock).mock.calls[0][1]; const event = (logRipgrepFallback as Mock).mock.calls[0][1];
expect(event.error).toBe(String(error)); expect(event.error).toBe(`ripGrep check failed`);
}); });
it('should register GrepTool when useRipgrep is false', async () => { it('should register GrepTool when useRipgrep is false', async () => {

View File

@@ -81,6 +81,8 @@ import {
import { shouldAttemptBrowserLaunch } from '../utils/browser.js'; import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
import { FileExclusions } from '../utils/ignorePatterns.js'; import { FileExclusions } from '../utils/ignorePatterns.js';
import { WorkspaceContext } from '../utils/workspaceContext.js'; import { WorkspaceContext } from '../utils/workspaceContext.js';
import { isToolEnabled, type ToolName } from '../utils/tool-utils.js';
import { getErrorMessage } from '../utils/errors.js';
// Local config modules // Local config modules
import type { FileFilteringOptions } from './constants.js'; import type { FileFilteringOptions } from './constants.js';
@@ -279,7 +281,6 @@ export interface ConfigParameters {
skipNextSpeakerCheck?: boolean; skipNextSpeakerCheck?: boolean;
shellExecutionConfig?: ShellExecutionConfig; shellExecutionConfig?: ShellExecutionConfig;
extensionManagement?: boolean; extensionManagement?: boolean;
enablePromptCompletion?: boolean;
skipLoopDetection?: boolean; skipLoopDetection?: boolean;
vlmSwitchMode?: string; vlmSwitchMode?: string;
truncateToolOutputThreshold?: number; truncateToolOutputThreshold?: number;
@@ -376,7 +377,6 @@ export class Config {
private readonly skipNextSpeakerCheck: boolean; private readonly skipNextSpeakerCheck: boolean;
private shellExecutionConfig: ShellExecutionConfig; private shellExecutionConfig: ShellExecutionConfig;
private readonly extensionManagement: boolean = true; private readonly extensionManagement: boolean = true;
private readonly enablePromptCompletion: boolean = false;
private readonly skipLoopDetection: boolean; private readonly skipLoopDetection: boolean;
private readonly skipStartupContext: boolean; private readonly skipStartupContext: boolean;
private readonly vlmSwitchMode: string | undefined; private readonly vlmSwitchMode: string | undefined;
@@ -494,7 +494,6 @@ export class Config {
this.useSmartEdit = params.useSmartEdit ?? false; this.useSmartEdit = params.useSmartEdit ?? false;
this.extensionManagement = params.extensionManagement ?? true; this.extensionManagement = params.extensionManagement ?? true;
this.storage = new Storage(this.targetDir); this.storage = new Storage(this.targetDir);
this.enablePromptCompletion = params.enablePromptCompletion ?? false;
this.vlmSwitchMode = params.vlmSwitchMode; this.vlmSwitchMode = params.vlmSwitchMode;
this.fileExclusions = new FileExclusions(this); this.fileExclusions = new FileExclusions(this);
this.eventEmitter = params.eventEmitter; this.eventEmitter = params.eventEmitter;
@@ -561,7 +560,7 @@ export class Config {
} }
} }
async refreshAuth(authMethod: AuthType) { async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) {
// Vertex and Genai have incompatible encryption and sending history with // Vertex and Genai have incompatible encryption and sending history with
// throughtSignature from Genai to Vertex will fail, we need to strip them // throughtSignature from Genai to Vertex will fail, we need to strip them
if ( if (
@@ -581,6 +580,7 @@ export class Config {
newContentGeneratorConfig, newContentGeneratorConfig,
this, this,
this.getSessionId(), this.getSessionId(),
isInitialAuth,
); );
// Only assign to instance properties after successful initialization // Only assign to instance properties after successful initialization
this.contentGeneratorConfig = newContentGeneratorConfig; this.contentGeneratorConfig = newContentGeneratorConfig;
@@ -1036,10 +1036,6 @@ export class Config {
return this.accessibility.screenReader ?? false; return this.accessibility.screenReader ?? false;
} }
getEnablePromptCompletion(): boolean {
return this.enablePromptCompletion;
}
getSkipLoopDetection(): boolean { getSkipLoopDetection(): boolean {
return this.skipLoopDetection; return this.skipLoopDetection;
} }
@@ -1110,37 +1106,35 @@ export class Config {
async createToolRegistry(): Promise<ToolRegistry> { async createToolRegistry(): Promise<ToolRegistry> {
const registry = new ToolRegistry(this, this.eventEmitter); const registry = new ToolRegistry(this, this.eventEmitter);
// helper to create & register core tools that are enabled const coreToolsConfig = this.getCoreTools();
const excludeToolsConfig = this.getExcludeTools();
// Helper to create & register core tools that are enabled
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const registerCoreTool = (ToolClass: any, ...args: unknown[]) => { const registerCoreTool = (ToolClass: any, ...args: unknown[]) => {
const className = ToolClass.name; const toolName = ToolClass?.Name as ToolName | undefined;
const toolName = ToolClass.Name || className; const className = ToolClass?.name ?? 'UnknownTool';
const coreTools = this.getCoreTools();
const excludeTools = this.getExcludeTools() || [];
// On some platforms, the className can be minified to _ClassName.
const normalizedClassName = className.replace(/^_+/, '');
let isEnabled = true; // Enabled by default if coreTools is not set. if (!toolName) {
if (coreTools) { // Log warning and skip this tool instead of crashing
isEnabled = coreTools.some( console.warn(
(tool) => `[Config] Skipping tool registration: ${className} is missing static Name property. ` +
tool === toolName || `Tools must define a static Name property to be registered. ` +
tool === normalizedClassName || `Location: config.ts:registerCoreTool`,
tool.startsWith(`${toolName}(`) ||
tool.startsWith(`${normalizedClassName}(`),
); );
return;
} }
const isExcluded = excludeTools.some( if (isToolEnabled(toolName, coreToolsConfig, excludeToolsConfig)) {
(tool) => tool === toolName || tool === normalizedClassName, try {
); registry.registerTool(new ToolClass(...args));
} catch (error) {
if (isExcluded) { console.error(
isEnabled = false; `[Config] Failed to register tool ${className} (${toolName}):`,
} error,
);
if (isEnabled) { throw error; // Re-throw after logging context
registry.registerTool(new ToolClass(...args)); }
} }
}; };
@@ -1154,17 +1148,20 @@ export class Config {
try { try {
useRipgrep = await canUseRipgrep(this.getUseBuiltinRipgrep()); useRipgrep = await canUseRipgrep(this.getUseBuiltinRipgrep());
} catch (error: unknown) { } catch (error: unknown) {
errorString = String(error); errorString = getErrorMessage(error);
} }
if (useRipgrep) { if (useRipgrep) {
registerCoreTool(RipGrepTool, this); registerCoreTool(RipGrepTool, this);
} else { } else {
errorString =
errorString ||
'Ripgrep is not available. Please install ripgrep globally.';
// Log for telemetry // Log for telemetry
logRipgrepFallback(this, new RipgrepFallbackEvent(errorString)); logRipgrepFallback(
this,
new RipgrepFallbackEvent(
this.getUseRipgrep(),
this.getUseBuiltinRipgrep(),
errorString || 'ripgrep is not available',
),
);
registerCoreTool(GrepTool, this); registerCoreTool(GrepTool, this);
} }
} else { } else {

View File

@@ -120,6 +120,7 @@ export async function createContentGenerator(
config: ContentGeneratorConfig, config: ContentGeneratorConfig,
gcConfig: Config, gcConfig: Config,
sessionId?: string, sessionId?: string,
isInitialAuth?: boolean,
): Promise<ContentGenerator> { ): Promise<ContentGenerator> {
const version = process.env['CLI_VERSION'] || process.version; const version = process.env['CLI_VERSION'] || process.version;
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
@@ -191,13 +192,17 @@ export async function createContentGenerator(
try { try {
// Get the Qwen OAuth client (now includes integrated token management) // Get the Qwen OAuth client (now includes integrated token management)
const qwenClient = await getQwenOauthClient(gcConfig); // If this is initial auth, require cached credentials to detect missing credentials
const qwenClient = await getQwenOauthClient(
gcConfig,
isInitialAuth ? { requireCachedCredentials: true } : undefined,
);
// Create the content generator with dynamic token management // Create the content generator with dynamic token management
return new QwenContentGenerator(qwenClient, config, gcConfig); return new QwenContentGenerator(qwenClient, config, gcConfig);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to initialize Qwen: ${error instanceof Error ? error.message : String(error)}`, `${error instanceof Error ? error.message : String(error)}`,
); );
} }
} }

View File

@@ -23,6 +23,14 @@ import type OpenAI from 'openai';
import { safeJsonParse } from '../../utils/safeJsonParse.js'; import { safeJsonParse } from '../../utils/safeJsonParse.js';
import { StreamingToolCallParser } from './streamingToolCallParser.js'; import { StreamingToolCallParser } from './streamingToolCallParser.js';
/**
* Extended usage type that supports both OpenAI standard format and alternative formats
* Some models return cached_tokens at the top level instead of in prompt_tokens_details
*/
interface ExtendedCompletionUsage extends OpenAI.CompletionUsage {
cached_tokens?: number;
}
/** /**
* Tool call accumulator for streaming responses * Tool call accumulator for streaming responses
*/ */
@@ -582,7 +590,13 @@ export class OpenAIContentConverter {
const promptTokens = usage.prompt_tokens || 0; const promptTokens = usage.prompt_tokens || 0;
const completionTokens = usage.completion_tokens || 0; const completionTokens = usage.completion_tokens || 0;
const totalTokens = usage.total_tokens || 0; const totalTokens = usage.total_tokens || 0;
const cachedTokens = usage.prompt_tokens_details?.cached_tokens || 0; // Support both formats: prompt_tokens_details.cached_tokens (OpenAI standard)
// and cached_tokens (some models return it at top level)
const extendedUsage = usage as ExtendedCompletionUsage;
const cachedTokens =
usage.prompt_tokens_details?.cached_tokens ??
extendedUsage.cached_tokens ??
0;
// If we only have total tokens but no breakdown, estimate the split // If we only have total tokens but no breakdown, estimate the split
// Typically input is ~70% and output is ~30% for most conversations // Typically input is ~70% and output is ~30% for most conversations
@@ -707,7 +721,13 @@ export class OpenAIContentConverter {
const promptTokens = usage.prompt_tokens || 0; const promptTokens = usage.prompt_tokens || 0;
const completionTokens = usage.completion_tokens || 0; const completionTokens = usage.completion_tokens || 0;
const totalTokens = usage.total_tokens || 0; const totalTokens = usage.total_tokens || 0;
const cachedTokens = usage.prompt_tokens_details?.cached_tokens || 0; // Support both formats: prompt_tokens_details.cached_tokens (OpenAI standard)
// and cached_tokens (some models return it at top level)
const extendedUsage = usage as ExtendedCompletionUsage;
const cachedTokens =
usage.prompt_tokens_details?.cached_tokens ??
extendedUsage.cached_tokens ??
0;
// If we only have total tokens but no breakdown, estimate the split // If we only have total tokens but no breakdown, estimate the split
// Typically input is ~70% and output is ~30% for most conversations // Typically input is ~70% and output is ~30% for most conversations

View File

@@ -13,6 +13,7 @@ import { OpenAIContentGenerator } from './openaiContentGenerator.js';
import { import {
DashScopeOpenAICompatibleProvider, DashScopeOpenAICompatibleProvider,
DeepSeekOpenAICompatibleProvider, DeepSeekOpenAICompatibleProvider,
ModelScopeOpenAICompatibleProvider,
OpenRouterOpenAICompatibleProvider, OpenRouterOpenAICompatibleProvider,
type OpenAICompatibleProvider, type OpenAICompatibleProvider,
DefaultOpenAICompatibleProvider, DefaultOpenAICompatibleProvider,
@@ -78,6 +79,14 @@ export function determineProvider(
); );
} }
// Check for ModelScope provider
if (ModelScopeOpenAICompatibleProvider.isModelScopeProvider(config)) {
return new ModelScopeOpenAICompatibleProvider(
contentGeneratorConfig,
cliConfig,
);
}
// Default provider for standard OpenAI-compatible APIs // Default provider for standard OpenAI-compatible APIs
return new DefaultOpenAICompatibleProvider(contentGeneratorConfig, cliConfig); return new DefaultOpenAICompatibleProvider(contentGeneratorConfig, cliConfig);
} }

View File

@@ -1,3 +1,4 @@
export { ModelScopeOpenAICompatibleProvider } from './modelscope.js';
export { DashScopeOpenAICompatibleProvider } from './dashscope.js'; export { DashScopeOpenAICompatibleProvider } from './dashscope.js';
export { DeepSeekOpenAICompatibleProvider } from './deepseek.js'; export { DeepSeekOpenAICompatibleProvider } from './deepseek.js';
export { OpenRouterOpenAICompatibleProvider } from './openrouter.js'; export { OpenRouterOpenAICompatibleProvider } from './openrouter.js';

View File

@@ -0,0 +1,96 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type OpenAI from 'openai';
import { ModelScopeOpenAICompatibleProvider } from './modelscope.js';
import type { Config } from '../../../config/config.js';
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
vi.mock('openai');
describe('ModelScopeOpenAICompatibleProvider', () => {
let provider: ModelScopeOpenAICompatibleProvider;
let mockContentGeneratorConfig: ContentGeneratorConfig;
let mockCliConfig: Config;
beforeEach(() => {
mockContentGeneratorConfig = {
apiKey: 'test-api-key',
baseUrl: 'https://api.modelscope.cn/v1',
model: 'qwen-max',
} as ContentGeneratorConfig;
mockCliConfig = {
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
provider = new ModelScopeOpenAICompatibleProvider(
mockContentGeneratorConfig,
mockCliConfig,
);
});
describe('isModelScopeProvider', () => {
it('should return true if baseUrl includes "modelscope"', () => {
const config = { baseUrl: 'https://api.modelscope.cn/v1' };
expect(
ModelScopeOpenAICompatibleProvider.isModelScopeProvider(
config as ContentGeneratorConfig,
),
).toBe(true);
});
it('should return false if baseUrl does not include "modelscope"', () => {
const config = { baseUrl: 'https://api.openai.com/v1' };
expect(
ModelScopeOpenAICompatibleProvider.isModelScopeProvider(
config as ContentGeneratorConfig,
),
).toBe(false);
});
});
describe('buildRequest', () => {
it('should remove stream_options when stream is false', () => {
const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'qwen-max',
messages: [{ role: 'user', content: 'Hello!' }],
stream: false,
stream_options: { include_usage: true },
};
const result = provider.buildRequest(originalRequest, 'prompt-id');
expect(result).not.toHaveProperty('stream_options');
});
it('should keep stream_options when stream is true', () => {
const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'qwen-max',
messages: [{ role: 'user', content: 'Hello!' }],
stream: true,
stream_options: { include_usage: true },
};
const result = provider.buildRequest(originalRequest, 'prompt-id');
expect(result).toHaveProperty('stream_options');
});
it('should handle requests without stream_options', () => {
const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'qwen-max',
messages: [{ role: 'user', content: 'Hello!' }],
stream: false,
};
const result = provider.buildRequest(originalRequest, 'prompt-id');
expect(result).not.toHaveProperty('stream_options');
});
});
});

View File

@@ -0,0 +1,32 @@
import type OpenAI from 'openai';
import { DefaultOpenAICompatibleProvider } from './default.js';
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
/**
* Provider for ModelScope API
*/
export class ModelScopeOpenAICompatibleProvider extends DefaultOpenAICompatibleProvider {
/**
* Checks if the configuration is for ModelScope.
*/
static isModelScopeProvider(config: ContentGeneratorConfig): boolean {
return !!config.baseUrl?.includes('modelscope');
}
/**
* ModelScope does not support `stream_options` when `stream` is false.
* This method removes `stream_options` if `stream` is not true.
*/
override buildRequest(
request: OpenAI.Chat.ChatCompletionCreateParams,
userPromptId: string,
): OpenAI.Chat.ChatCompletionCreateParams {
const newRequest = super.buildRequest(request, userPromptId);
if (!newRequest.stream) {
delete (newRequest as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming)
.stream_options;
}
return newRequest;
}
}

View File

@@ -165,9 +165,7 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [
// ------------------- // -------------------
// DeepSeek // DeepSeek
// ------------------- // -------------------
[/^deepseek$/, LIMITS['128k']], [/^deepseek(?:-.*)?$/, LIMITS['128k']],
[/^deepseek-r1(?:-.*)?$/, LIMITS['128k']],
[/^deepseek-v3(?:\.\d+)?(?:-.*)?$/, LIMITS['128k']],
// ------------------- // -------------------
// Moonshot / Kimi // Moonshot / Kimi
@@ -211,6 +209,12 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [
// Qwen3-VL-Plus: 32K max output tokens // Qwen3-VL-Plus: 32K max output tokens
[/^qwen3-vl-plus$/, LIMITS['32k']], [/^qwen3-vl-plus$/, LIMITS['32k']],
// Deepseek-chat: 8k max tokens
[/^deepseek-chat$/, LIMITS['8k']],
// Deepseek-reasoner: 64k max tokens
[/^deepseek-reasoner$/, LIMITS['64k']],
]; ];
/** /**

View File

@@ -102,6 +102,8 @@ export * from './tools/web-search/index.js';
export * from './tools/read-many-files.js'; export * from './tools/read-many-files.js';
export * from './tools/mcp-client.js'; export * from './tools/mcp-client.js';
export * from './tools/mcp-tool.js'; export * from './tools/mcp-tool.js';
export * from './tools/task.js';
export * from './tools/todoWrite.js';
// MCP OAuth // MCP OAuth
export { MCPOAuthProvider } from './mcp/oauth-provider.js'; export { MCPOAuthProvider } from './mcp/oauth-provider.js';

View File

@@ -623,14 +623,16 @@ describe('QwenOAuth2Client', () => {
}); });
it('should handle authorization_pending with HTTP 400 according to RFC 8628', async () => { it('should handle authorization_pending with HTTP 400 according to RFC 8628', async () => {
const errorData = {
error: 'authorization_pending',
error_description: 'The authorization request is still pending',
};
const mockResponse = { const mockResponse = {
ok: false, ok: false,
status: 400, status: 400,
statusText: 'Bad Request', statusText: 'Bad Request',
json: async () => ({ text: async () => JSON.stringify(errorData),
error: 'authorization_pending', json: async () => errorData,
error_description: 'The authorization request is still pending',
}),
}; };
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
@@ -646,14 +648,16 @@ describe('QwenOAuth2Client', () => {
}); });
it('should handle slow_down with HTTP 429 according to RFC 8628', async () => { it('should handle slow_down with HTTP 429 according to RFC 8628', async () => {
const errorData = {
error: 'slow_down',
error_description: 'The client is polling too frequently',
};
const mockResponse = { const mockResponse = {
ok: false, ok: false,
status: 429, status: 429,
statusText: 'Too Many Requests', statusText: 'Too Many Requests',
json: async () => ({ text: async () => JSON.stringify(errorData),
error: 'slow_down', json: async () => errorData,
error_description: 'The client is polling too frequently',
}),
}; };
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
@@ -825,7 +829,7 @@ describe('getQwenOAuthClient', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication failed'); ).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -983,7 +987,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication failed'); ).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -1032,7 +1036,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication timed out'); ).rejects.toThrow('Authorization timeout, please restart the process.');
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -1082,7 +1086,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow( ).rejects.toThrow(
'Too many request for Qwen OAuth authentication, please try again later.', 'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
); );
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
@@ -1119,7 +1123,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication failed'); ).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -1177,7 +1181,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication failed'); ).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -1264,7 +1268,9 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
import('./qwenOAuth2.js').then((module) => import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig), module.getQwenOAuthClient(mockConfig),
), ),
).rejects.toThrow('Qwen OAuth authentication failed'); ).rejects.toThrow(
'Device code expired or invalid, please restart the authorization process.',
);
SharedTokenManager.getInstance = originalGetInstance; SharedTokenManager.getInstance = originalGetInstance;
}); });
@@ -1991,14 +1997,16 @@ describe('Enhanced Error Handling and Edge Cases', () => {
}); });
it('should handle authorization_pending with correct status', async () => { it('should handle authorization_pending with correct status', async () => {
const errorData = {
error: 'authorization_pending',
error_description: 'Authorization request is pending',
};
const mockResponse = { const mockResponse = {
ok: false, ok: false,
status: 400, status: 400,
statusText: 'Bad Request', statusText: 'Bad Request',
json: vi.fn().mockResolvedValue({ text: vi.fn().mockResolvedValue(JSON.stringify(errorData)),
error: 'authorization_pending', json: vi.fn().mockResolvedValue(errorData),
error_description: 'Authorization request is pending',
}),
}; };
vi.mocked(global.fetch).mockResolvedValue( vi.mocked(global.fetch).mockResolvedValue(

View File

@@ -345,44 +345,47 @@ export class QwenOAuth2Client implements IQwenOAuth2Client {
}); });
if (!response.ok) { if (!response.ok) {
// Parse the response as JSON to check for OAuth RFC 8628 standard errors // Read response body as text first (can only be read once)
const responseText = await response.text();
// Try to parse as JSON to check for OAuth RFC 8628 standard errors
let errorData: ErrorData | null = null;
try { try {
const errorData = (await response.json()) as ErrorData; errorData = JSON.parse(responseText) as ErrorData;
// According to OAuth RFC 8628, handle standard polling responses
if (
response.status === 400 &&
errorData.error === 'authorization_pending'
) {
// User has not yet approved the authorization request. Continue polling.
return { status: 'pending' } as DeviceTokenPendingData;
}
if (response.status === 429 && errorData.error === 'slow_down') {
// Client is polling too frequently. Return pending with slowDown flag.
return {
status: 'pending',
slowDown: true,
} as DeviceTokenPendingData;
}
// Handle other 400 errors (access_denied, expired_token, etc.) as real errors
// For other errors, throw with proper error information
const error = new Error(
`Device token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description || 'No details provided'}`,
);
(error as Error & { status?: number }).status = response.status;
throw error;
} catch (_parseError) { } catch (_parseError) {
// If JSON parsing fails, fall back to text response // If JSON parsing fails, use text response
const errorData = await response.text();
const error = new Error( const error = new Error(
`Device token poll failed: ${response.status} ${response.statusText}. Response: ${errorData}`, `Device token poll failed: ${response.status} ${response.statusText}. Response: ${responseText}`,
); );
(error as Error & { status?: number }).status = response.status; (error as Error & { status?: number }).status = response.status;
throw error; throw error;
} }
// According to OAuth RFC 8628, handle standard polling responses
if (
response.status === 400 &&
errorData.error === 'authorization_pending'
) {
// User has not yet approved the authorization request. Continue polling.
return { status: 'pending' } as DeviceTokenPendingData;
}
if (response.status === 429 && errorData.error === 'slow_down') {
// Client is polling too frequently. Return pending with slowDown flag.
return {
status: 'pending',
slowDown: true,
} as DeviceTokenPendingData;
}
// Handle other 400 errors (access_denied, expired_token, etc.) as real errors
// For other errors, throw with proper error information
const error = new Error(
`Device token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description}`,
);
(error as Error & { status?: number }).status = response.status;
throw error;
} }
return (await response.json()) as DeviceTokenResponse; return (await response.json()) as DeviceTokenResponse;
@@ -467,6 +470,7 @@ export type AuthResult =
| { | {
success: false; success: false;
reason: 'timeout' | 'cancelled' | 'error' | 'rate_limit'; reason: 'timeout' | 'cancelled' | 'error' | 'rate_limit';
message?: string; // Detailed error message for better error reporting
}; };
/** /**
@@ -476,6 +480,7 @@ export const qwenOAuth2Events = new EventEmitter();
export async function getQwenOAuthClient( export async function getQwenOAuthClient(
config: Config, config: Config,
options?: { requireCachedCredentials?: boolean },
): Promise<QwenOAuth2Client> { ): Promise<QwenOAuth2Client> {
const client = new QwenOAuth2Client(); const client = new QwenOAuth2Client();
@@ -488,11 +493,6 @@ export async function getQwenOAuthClient(
client.setCredentials(credentials); client.setCredentials(credentials);
return client; return client;
} catch (error: unknown) { } catch (error: unknown) {
console.debug(
'Shared token manager failed, attempting device flow:',
error,
);
// Handle specific token manager errors // Handle specific token manager errors
if (error instanceof TokenManagerError) { if (error instanceof TokenManagerError) {
switch (error.type) { switch (error.type) {
@@ -520,12 +520,20 @@ export async function getQwenOAuthClient(
// Try device flow instead of forcing refresh // Try device flow instead of forcing refresh
const result = await authWithQwenDeviceFlow(client, config); const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) { if (!result.success) {
throw new Error('Qwen OAuth authentication failed'); // Use detailed error message if available, otherwise use default
const errorMessage =
result.message || 'Qwen OAuth authentication failed';
throw new Error(errorMessage);
} }
return client; return client;
} }
// No cached credentials, use device authorization flow for authentication if (options?.requireCachedCredentials) {
throw new Error(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
}
const result = await authWithQwenDeviceFlow(client, config); const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) { if (!result.success) {
// Only emit timeout event if the failure reason is actually timeout // Only emit timeout event if the failure reason is actually timeout
@@ -538,20 +546,24 @@ export async function getQwenOAuthClient(
); );
} }
// Throw error with appropriate message based on failure reason // Use detailed error message if available, otherwise use default based on reason
switch (result.reason) { const errorMessage =
case 'timeout': result.message ||
throw new Error('Qwen OAuth authentication timed out'); (() => {
case 'cancelled': switch (result.reason) {
throw new Error('Qwen OAuth authentication was cancelled by user'); case 'timeout':
case 'rate_limit': return 'Qwen OAuth authentication timed out';
throw new Error( case 'cancelled':
'Too many request for Qwen OAuth authentication, please try again later.', return 'Qwen OAuth authentication was cancelled by user';
); case 'rate_limit':
case 'error': return 'Too many request for Qwen OAuth authentication, please try again later.';
default: case 'error':
throw new Error('Qwen OAuth authentication failed'); default:
} return 'Qwen OAuth authentication failed';
}
})();
throw new Error(errorMessage);
} }
return client; return client;
@@ -644,13 +656,10 @@ async function authWithQwenDeviceFlow(
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Check if authentication was cancelled // Check if authentication was cancelled
if (isCancelled) { if (isCancelled) {
console.debug('\nAuthentication cancelled by user.'); const message = 'Authentication cancelled by user.';
qwenOAuth2Events.emit( console.debug('\n' + message);
QwenOAuth2Event.AuthProgress, qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
'error', return { success: false, reason: 'cancelled', message };
'Authentication cancelled by user.',
);
return { success: false, reason: 'cancelled' };
} }
try { try {
@@ -738,13 +747,14 @@ async function authWithQwenDeviceFlow(
// Check for cancellation after waiting // Check for cancellation after waiting
if (isCancelled) { if (isCancelled) {
console.debug('\nAuthentication cancelled by user.'); const message = 'Authentication cancelled by user.';
console.debug('\n' + message);
qwenOAuth2Events.emit( qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress, QwenOAuth2Event.AuthProgress,
'error', 'error',
'Authentication cancelled by user.', message,
); );
return { success: false, reason: 'cancelled' }; return { success: false, reason: 'cancelled', message };
} }
continue; continue;
@@ -758,7 +768,7 @@ async function authWithQwenDeviceFlow(
); );
} }
} catch (error: unknown) { } catch (error: unknown) {
// Handle specific error cases // Extract error information
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
const statusCode = const statusCode =
@@ -766,42 +776,49 @@ async function authWithQwenDeviceFlow(
? (error as Error & { status?: number }).status ? (error as Error & { status?: number }).status
: null; : null;
if (errorMessage.includes('401') || statusCode === 401) { // Helper function to handle error and stop polling
const message = const handleError = (
'Device code expired or invalid, please restart the authorization process.'; reason: 'error' | 'rate_limit',
message: string,
// Emit error event eventType: 'error' | 'rate_limit' = 'error',
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); ): AuthResult => {
return { success: false, reason: 'error' };
}
// Handle 429 Too Many Requests error
if (errorMessage.includes('429') || statusCode === 429) {
const message =
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.';
// Emit rate limit event to notify user
qwenOAuth2Events.emit( qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress, QwenOAuth2Event.AuthProgress,
'rate_limit', eventType,
message, message,
); );
console.error('\n' + message);
return { success: false, reason, message };
};
console.log('\n' + message); // Handle credential caching failures - stop polling immediately
if (errorMessage.includes('Failed to cache credentials')) {
return handleError('error', errorMessage);
}
// Return false to stop polling and go back to auth selection // Handle 401 Unauthorized - device code expired or invalid
return { success: false, reason: 'rate_limit' }; if (errorMessage.includes('401') || statusCode === 401) {
return handleError(
'error',
'Device code expired or invalid, please restart the authorization process.',
);
}
// Handle 429 Too Many Requests - rate limiting
if (errorMessage.includes('429') || statusCode === 429) {
return handleError(
'rate_limit',
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
'rate_limit',
);
} }
const message = `Error polling for token: ${errorMessage}`; const message = `Error polling for token: ${errorMessage}`;
// Emit error event
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
// Check for cancellation before waiting
if (isCancelled) { if (isCancelled) {
return { success: false, reason: 'cancelled' }; const message = 'Authentication cancelled by user.';
return { success: false, reason: 'cancelled', message };
} }
await new Promise((resolve) => setTimeout(resolve, pollInterval)); await new Promise((resolve) => setTimeout(resolve, pollInterval));
@@ -818,11 +835,12 @@ async function authWithQwenDeviceFlow(
); );
console.error('\n' + timeoutMessage); console.error('\n' + timeoutMessage);
return { success: false, reason: 'timeout' }; return { success: false, reason: 'timeout', message: timeoutMessage };
} catch (error: unknown) { } catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Device authorization flow failed:', errorMessage); const message = `Device authorization flow failed: ${errorMessage}`;
return { success: false, reason: 'error' }; console.error(message);
return { success: false, reason: 'error', message };
} finally { } finally {
// Clean up event listener // Clean up event listener
qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler); qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler);
@@ -852,10 +870,30 @@ async function loadCachedQwenCredentials(
async function cacheQwenCredentials(credentials: QwenCredentials) { async function cacheQwenCredentials(credentials: QwenCredentials) {
const filePath = getQwenCachedCredentialPath(); const filePath = getQwenCachedCredentialPath();
await fs.mkdir(path.dirname(filePath), { recursive: true }); try {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const credString = JSON.stringify(credentials, null, 2); const credString = JSON.stringify(credentials, null, 2);
await fs.writeFile(filePath, credString); await fs.writeFile(filePath, credString);
} catch (error: unknown) {
// Handle file system errors (e.g., EACCES permission denied)
const errorMessage = error instanceof Error ? error.message : String(error);
const errorCode =
error instanceof Error && 'code' in error
? (error as Error & { code?: string }).code
: undefined;
if (errorCode === 'EACCES') {
throw new Error(
`Failed to cache credentials: Permission denied (EACCES). Current user has no permission to access \`${filePath}\`. Please check permissions.`,
);
}
// Throw error for other file system failures
throw new Error(
`Failed to cache credentials: error when creating folder \`${path.dirname(filePath)}\` and writing to \`${filePath}\`. ${errorMessage}. Please check permissions.`,
);
}
} }
/** /**

View File

@@ -19,10 +19,10 @@ import * as path from 'node:path';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import * as os from 'node:os'; import * as os from 'node:os';
import { getProjectHash, QWEN_DIR } from '../utils/paths.js'; import { getProjectHash, QWEN_DIR } from '../utils/paths.js';
import { spawnAsync } from '../utils/shell-utils.js'; import { isCommandAvailable } from '../utils/shell-utils.js';
vi.mock('../utils/shell-utils.js', () => ({ vi.mock('../utils/shell-utils.js', () => ({
spawnAsync: vi.fn(), isCommandAvailable: vi.fn(),
})); }));
const hoistedMockEnv = vi.hoisted(() => vi.fn()); const hoistedMockEnv = vi.hoisted(() => vi.fn());
@@ -76,10 +76,7 @@ describe('GitService', () => {
vi.clearAllMocks(); vi.clearAllMocks();
hoistedIsGitRepositoryMock.mockReturnValue(true); hoistedIsGitRepositoryMock.mockReturnValue(true);
(spawnAsync as Mock).mockResolvedValue({ (isCommandAvailable as Mock).mockReturnValue({ available: true });
stdout: 'git version 2.0.0',
stderr: '',
});
hoistedMockHomedir.mockReturnValue(homedir); hoistedMockHomedir.mockReturnValue(homedir);
@@ -119,23 +116,9 @@ describe('GitService', () => {
}); });
}); });
describe('verifyGitAvailability', () => {
it('should resolve true if git --version command succeeds', async () => {
const service = new GitService(projectRoot, storage);
await expect(service.verifyGitAvailability()).resolves.toBe(true);
expect(spawnAsync).toHaveBeenCalledWith('git', ['--version']);
});
it('should resolve false if git --version command fails', async () => {
(spawnAsync as Mock).mockRejectedValue(new Error('git not found'));
const service = new GitService(projectRoot, storage);
await expect(service.verifyGitAvailability()).resolves.toBe(false);
});
});
describe('initialize', () => { describe('initialize', () => {
it('should throw an error if Git is not available', async () => { it('should throw an error if Git is not available', async () => {
(spawnAsync as Mock).mockRejectedValue(new Error('git not found')); (isCommandAvailable as Mock).mockReturnValue({ available: false });
const service = new GitService(projectRoot, storage); const service = new GitService(projectRoot, storage);
await expect(service.initialize()).rejects.toThrow( await expect(service.initialize()).rejects.toThrow(
'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.', 'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.',

View File

@@ -6,7 +6,7 @@
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import * as path from 'node:path'; import * as path from 'node:path';
import { spawnAsync } from '../utils/shell-utils.js'; import { isCommandAvailable } from '../utils/shell-utils.js';
import type { SimpleGit } from 'simple-git'; import type { SimpleGit } from 'simple-git';
import { simpleGit, CheckRepoActions } from 'simple-git'; import { simpleGit, CheckRepoActions } from 'simple-git';
import type { Storage } from '../config/storage.js'; import type { Storage } from '../config/storage.js';
@@ -26,7 +26,7 @@ export class GitService {
} }
async initialize(): Promise<void> { async initialize(): Promise<void> {
const gitAvailable = await this.verifyGitAvailability(); const { available: gitAvailable } = isCommandAvailable('git');
if (!gitAvailable) { if (!gitAvailable) {
throw new Error( throw new Error(
'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.', 'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.',
@@ -41,15 +41,6 @@ export class GitService {
} }
} }
async verifyGitAvailability(): Promise<boolean> {
try {
await spawnAsync('git', ['--version']);
return true;
} catch (_error) {
return false;
}
}
/** /**
* Creates a hidden git repository in the project root. * Creates a hidden git repository in the project root.
* The Git repository is used to support checkpointing. * The Git repository is used to support checkpointing.

View File

@@ -62,9 +62,10 @@ export type {
SubAgentToolResultEvent, SubAgentToolResultEvent,
SubAgentFinishEvent, SubAgentFinishEvent,
SubAgentErrorEvent, SubAgentErrorEvent,
SubAgentApprovalRequestEvent,
} from './subagent-events.js'; } from './subagent-events.js';
export { SubAgentEventEmitter } from './subagent-events.js'; export { SubAgentEventEmitter, SubAgentEventType } from './subagent-events.js';
// Statistics and formatting // Statistics and formatting
export type { export type {

View File

@@ -29,6 +29,7 @@ import { SubagentValidator } from './validation.js';
import { SubAgentScope } from './subagent.js'; import { SubAgentScope } from './subagent.js';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import { BuiltinAgentRegistry } from './builtin-agents.js'; import { BuiltinAgentRegistry } from './builtin-agents.js';
import { ToolDisplayNamesMigration } from '../tools/tool-names.js';
const QWEN_CONFIG_DIR = '.qwen'; const QWEN_CONFIG_DIR = '.qwen';
const AGENT_CONFIG_DIR = 'agents'; const AGENT_CONFIG_DIR = 'agents';
@@ -632,7 +633,12 @@ export class SubagentManager {
// If no exact name match, try to find by display name // If no exact name match, try to find by display name
const displayNameMatch = allTools.find( const displayNameMatch = allTools.find(
(tool) => tool.displayName === toolIdentifier, (tool) =>
tool.displayName === toolIdentifier ||
tool.displayName ===
(ToolDisplayNamesMigration[
toolIdentifier as keyof typeof ToolDisplayNamesMigration
] as string | undefined),
); );
if (displayNameMatch) { if (displayNameMatch) {
result.push(displayNameMatch.name); result.push(displayNameMatch.name);

View File

@@ -33,6 +33,7 @@ export const EVENT_MALFORMED_JSON_RESPONSE =
export const EVENT_FILE_OPERATION = 'qwen-code.file_operation'; export const EVENT_FILE_OPERATION = 'qwen-code.file_operation';
export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model'; export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution'; export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
export const EVENT_AUTH = 'qwen-code.auth';
// Performance Events // Performance Events
export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance'; export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance';

View File

@@ -43,6 +43,7 @@ export {
logExtensionUninstall, logExtensionUninstall,
logRipgrepFallback, logRipgrepFallback,
logNextSpeakerCheck, logNextSpeakerCheck,
logAuth,
} from './loggers.js'; } from './loggers.js';
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js'; export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
export { export {
@@ -61,6 +62,7 @@ export {
ToolOutputTruncatedEvent, ToolOutputTruncatedEvent,
RipgrepFallbackEvent, RipgrepFallbackEvent,
NextSpeakerCheckEvent, NextSpeakerCheckEvent,
AuthEvent,
} from './types.js'; } from './types.js';
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js'; export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
export type { TelemetryEvent } from './types.js'; export type { TelemetryEvent } from './types.js';

View File

@@ -447,7 +447,11 @@ describe('loggers', () => {
}); });
it('should log ripgrep fallback event', () => { it('should log ripgrep fallback event', () => {
const event = new RipgrepFallbackEvent(); const event = new RipgrepFallbackEvent(
false,
false,
'ripgrep is not available',
);
logRipgrepFallback(mockConfig, event); logRipgrepFallback(mockConfig, event);
@@ -460,13 +464,13 @@ describe('loggers', () => {
'session.id': 'test-session-id', 'session.id': 'test-session-id',
'user.email': 'test-user@example.com', 'user.email': 'test-user@example.com',
'event.name': EVENT_RIPGREP_FALLBACK, 'event.name': EVENT_RIPGREP_FALLBACK,
error: undefined, error: 'ripgrep is not available',
}), }),
); );
}); });
it('should log ripgrep fallback event with an error', () => { it('should log ripgrep fallback event with an error', () => {
const event = new RipgrepFallbackEvent('rg not found'); const event = new RipgrepFallbackEvent(false, false, 'rg not found');
logRipgrepFallback(mockConfig, event); logRipgrepFallback(mockConfig, event);

View File

@@ -37,6 +37,7 @@ import {
EVENT_SUBAGENT_EXECUTION, EVENT_SUBAGENT_EXECUTION,
EVENT_MALFORMED_JSON_RESPONSE, EVENT_MALFORMED_JSON_RESPONSE,
EVENT_INVALID_CHUNK, EVENT_INVALID_CHUNK,
EVENT_AUTH,
} from './constants.js'; } from './constants.js';
import { import {
recordApiErrorMetrics, recordApiErrorMetrics,
@@ -83,6 +84,7 @@ import type {
SubagentExecutionEvent, SubagentExecutionEvent,
MalformedJsonResponseEvent, MalformedJsonResponseEvent,
InvalidChunkEvent, InvalidChunkEvent,
AuthEvent,
} from './types.js'; } from './types.js';
import type { UiEvent } from './uiTelemetry.js'; import type { UiEvent } from './uiTelemetry.js';
import { uiTelemetryService } from './uiTelemetry.js'; import { uiTelemetryService } from './uiTelemetry.js';
@@ -312,7 +314,7 @@ export function logRipgrepFallback(
config: Config, config: Config,
event: RipgrepFallbackEvent, event: RipgrepFallbackEvent,
): void { ): void {
QwenLogger.getInstance(config)?.logRipgrepFallbackEvent(); QwenLogger.getInstance(config)?.logRipgrepFallbackEvent(event);
if (!isTelemetrySdkInitialized()) return; if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = { const attributes: LogAttributes = {
@@ -838,3 +840,29 @@ export function logExtensionDisable(
}; };
logger.emit(logRecord); logger.emit(logRecord);
} }
export function logAuth(config: Config, event: AuthEvent): void {
QwenLogger.getInstance(config)?.logAuthEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
...event,
'event.name': EVENT_AUTH,
'event.timestamp': new Date().toISOString(),
auth_type: event.auth_type,
action_type: event.action_type,
status: event.status,
};
if (event.error_message) {
attributes['error.message'] = event.error_message;
}
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `Auth event: ${event.action_type} ${event.status} for ${event.auth_type}`,
attributes,
};
logger.emit(logRecord);
}

View File

@@ -19,6 +19,21 @@ export interface RumView {
name: string; name: string;
} }
export interface RumOS {
type?: string;
version?: string;
container?: string;
container_version?: string;
}
export interface RumDevice {
id?: string;
name?: string;
type?: string;
brand?: string;
model?: string;
}
export interface RumEvent { export interface RumEvent {
timestamp?: number; timestamp?: number;
event_type?: 'view' | 'action' | 'exception' | 'resource'; event_type?: 'view' | 'action' | 'exception' | 'resource';
@@ -78,6 +93,8 @@ export interface RumPayload {
user: RumUser; user: RumUser;
session: RumSession; session: RumSession;
view: RumView; view: RumView;
os?: RumOS;
device?: RumDevice;
events: RumEvent[]; events: RumEvent[];
properties?: Record<string, unknown>; properties?: Record<string, unknown>;
_v: string; _v: string;

View File

@@ -13,8 +13,10 @@ import {
afterEach, afterEach,
afterAll, afterAll,
} from 'vitest'; } from 'vitest';
import * as os from 'node:os';
import { QwenLogger, TEST_ONLY } from './qwen-logger.js'; import { QwenLogger, TEST_ONLY } from './qwen-logger.js';
import type { Config } from '../../config/config.js'; import type { Config } from '../../config/config.js';
import { AuthType } from '../../core/contentGenerator.js';
import { import {
StartSessionEvent, StartSessionEvent,
EndSessionEvent, EndSessionEvent,
@@ -22,7 +24,7 @@ import {
KittySequenceOverflowEvent, KittySequenceOverflowEvent,
IdeConnectionType, IdeConnectionType,
} from '../types.js'; } from '../types.js';
import type { RumEvent } from './event-types.js'; import type { RumEvent, RumPayload } from './event-types.js';
// Mock dependencies // Mock dependencies
vi.mock('../../utils/user_id.js', () => ({ vi.mock('../../utils/user_id.js', () => ({
@@ -46,6 +48,7 @@ const makeFakeConfig = (overrides: Partial<Config> = {}): Config => {
getCliVersion: () => '1.0.0', getCliVersion: () => '1.0.0',
getProxy: () => undefined, getProxy: () => undefined,
getContentGeneratorConfig: () => ({ authType: 'test-auth' }), getContentGeneratorConfig: () => ({ authType: 'test-auth' }),
getAuthType: () => AuthType.QWEN_OAUTH,
getMcpServers: () => ({}), getMcpServers: () => ({}),
getModel: () => 'test-model', getModel: () => 'test-model',
getEmbeddingModel: () => 'test-embedding', getEmbeddingModel: () => 'test-embedding',
@@ -102,6 +105,24 @@ describe('QwenLogger', () => {
}); });
}); });
describe('createRumPayload', () => {
it('includes os metadata in payload', async () => {
const logger = QwenLogger.getInstance(mockConfig)!;
const payload = await (
logger as unknown as {
createRumPayload(): Promise<RumPayload>;
}
).createRumPayload();
expect(payload.os).toEqual(
expect.objectContaining({
type: os.platform(),
version: os.release(),
}),
);
});
});
describe('event queue management', () => { describe('event queue management', () => {
it('should handle event overflow gracefully', () => { it('should handle event overflow gracefully', () => {
const debugConfig = makeFakeConfig({ getDebugMode: () => true }); const debugConfig = makeFakeConfig({ getDebugMode: () => true });

View File

@@ -6,6 +6,7 @@
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import * as https from 'https'; import * as https from 'https';
import * as os from 'node:os';
import { HttpsProxyAgent } from 'https-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent';
import type { import type {
@@ -36,6 +37,8 @@ import type {
ExtensionEnableEvent, ExtensionEnableEvent,
ModelSlashCommandEvent, ModelSlashCommandEvent,
ExtensionDisableEvent, ExtensionDisableEvent,
AuthEvent,
RipgrepFallbackEvent,
} from '../types.js'; } from '../types.js';
import { EndSessionEvent } from '../types.js'; import { EndSessionEvent } from '../types.js';
import type { import type {
@@ -45,10 +48,10 @@ import type {
RumResourceEvent, RumResourceEvent,
RumExceptionEvent, RumExceptionEvent,
RumPayload, RumPayload,
RumOS,
} from './event-types.js'; } from './event-types.js';
import type { Config } from '../../config/config.js'; import type { Config } from '../../config/config.js';
import { safeJsonStringify } from '../../utils/safeJsonStringify.js'; import { safeJsonStringify } from '../../utils/safeJsonStringify.js';
import { type HttpError, retryWithBackoff } from '../../utils/retry.js';
import { InstallationManager } from '../../utils/installationManager.js'; import { InstallationManager } from '../../utils/installationManager.js';
import { FixedDeque } from 'mnemonist'; import { FixedDeque } from 'mnemonist';
import { AuthType } from '../../core/contentGenerator.js'; import { AuthType } from '../../core/contentGenerator.js';
@@ -215,9 +218,17 @@ export class QwenLogger {
return this.createRumEvent('exception', type, name, properties); return this.createRumEvent('exception', type, name, properties);
} }
private getOsMetadata(): RumOS {
return {
type: os.platform(),
version: os.release(),
};
}
async createRumPayload(): Promise<RumPayload> { async createRumPayload(): Promise<RumPayload> {
const authType = this.config?.getAuthType(); const authType = this.config?.getAuthType();
const version = this.config?.getCliVersion() || 'unknown'; const version = this.config?.getCliVersion() || 'unknown';
const osMetadata = this.getOsMetadata();
return { return {
app: { app: {
@@ -236,6 +247,7 @@ export class QwenLogger {
id: this.sessionId, id: this.sessionId,
name: 'qwen-code-cli', name: 'qwen-code-cli',
}, },
os: osMetadata,
events: this.events.toArray() as RumEvent[], events: this.events.toArray() as RumEvent[],
properties: { properties: {
@@ -288,8 +300,8 @@ export class QwenLogger {
const rumPayload = await this.createRumPayload(); const rumPayload = await this.createRumPayload();
// Override events with the ones we're sending // Override events with the ones we're sending
rumPayload.events = eventsToSend; rumPayload.events = eventsToSend;
const flushFn = () => try {
new Promise<Buffer>((resolve, reject) => { await new Promise<Buffer>((resolve, reject) => {
const body = safeJsonStringify(rumPayload); const body = safeJsonStringify(rumPayload);
const options = { const options = {
hostname: USAGE_STATS_HOSTNAME, hostname: USAGE_STATS_HOSTNAME,
@@ -311,10 +323,9 @@ export class QwenLogger {
res.statusCode && res.statusCode &&
(res.statusCode < 200 || res.statusCode >= 300) (res.statusCode < 200 || res.statusCode >= 300)
) { ) {
const err: HttpError = new Error( const err = new Error(
`Request failed with status ${res.statusCode}`, `Request failed with status ${res.statusCode}`,
); );
err.status = res.statusCode;
res.resume(); res.resume();
return reject(err); return reject(err);
} }
@@ -326,26 +337,11 @@ export class QwenLogger {
req.end(body); req.end(body);
}); });
try {
await retryWithBackoff(flushFn, {
maxAttempts: 3,
initialDelayMs: 200,
shouldRetryOnError: (err: unknown) => {
if (!(err instanceof Error)) return false;
const status = (err as HttpError).status as number | undefined;
// If status is not available, it's likely a network error
if (status === undefined) return true;
// Retry on 429 (Too many Requests) and 5xx server errors.
return status === 429 || (status >= 500 && status < 600);
},
});
this.lastFlushTime = Date.now(); this.lastFlushTime = Date.now();
return {}; return {};
} catch (error) { } catch (error) {
if (this.config?.getDebugMode()) { if (this.config?.getDebugMode()) {
console.error('RUM flush failed after multiple retries.', error); console.error('RUM flush failed.', error);
} }
// Re-queue failed events for retry // Re-queue failed events for retry
@@ -752,6 +748,25 @@ export class QwenLogger {
this.flushIfNeeded(); this.flushIfNeeded();
} }
logAuthEvent(event: AuthEvent): void {
const snapshots: Record<string, unknown> = {
auth_type: event.auth_type,
action_type: event.action_type,
status: event.status,
};
if (event.error_message) {
snapshots['error_message'] = event.error_message;
}
const rumEvent = this.createActionEvent('auth', 'auth', {
snapshots: JSON.stringify(snapshots),
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
// misc events // misc events
logFlashFallbackEvent(event: FlashFallbackEvent): void { logFlashFallbackEvent(event: FlashFallbackEvent): void {
const rumEvent = this.createActionEvent('misc', 'flash_fallback', { const rumEvent = this.createActionEvent('misc', 'flash_fallback', {
@@ -764,8 +779,16 @@ export class QwenLogger {
this.flushIfNeeded(); this.flushIfNeeded();
} }
logRipgrepFallbackEvent(): void { logRipgrepFallbackEvent(event: RipgrepFallbackEvent): void {
const rumEvent = this.createActionEvent('misc', 'ripgrep_fallback', {}); const rumEvent = this.createActionEvent('misc', 'ripgrep_fallback', {
snapshots: JSON.stringify({
platform: process.platform,
arch: process.arch,
use_ripgrep: event.use_ripgrep,
use_builtin_ripgrep: event.use_builtin_ripgrep,
error: event.error ?? undefined,
}),
});
this.enqueueLogEvent(rumEvent); this.enqueueLogEvent(rumEvent);
this.flushIfNeeded(); this.flushIfNeeded();

View File

@@ -318,10 +318,20 @@ export class FlashFallbackEvent implements BaseTelemetryEvent {
export class RipgrepFallbackEvent implements BaseTelemetryEvent { export class RipgrepFallbackEvent implements BaseTelemetryEvent {
'event.name': 'ripgrep_fallback'; 'event.name': 'ripgrep_fallback';
'event.timestamp': string; 'event.timestamp': string;
use_ripgrep: boolean;
use_builtin_ripgrep: boolean;
error?: string;
constructor(public error?: string) { constructor(
use_ripgrep: boolean,
use_builtin_ripgrep: boolean,
error?: string,
) {
this['event.name'] = 'ripgrep_fallback'; this['event.name'] = 'ripgrep_fallback';
this['event.timestamp'] = new Date().toISOString(); this['event.timestamp'] = new Date().toISOString();
this.use_ripgrep = use_ripgrep;
this.use_builtin_ripgrep = use_builtin_ripgrep;
this.error = error;
} }
} }
@@ -686,6 +696,29 @@ export class SubagentExecutionEvent implements BaseTelemetryEvent {
} }
} }
export class AuthEvent implements BaseTelemetryEvent {
'event.name': 'auth';
'event.timestamp': string;
auth_type: AuthType;
action_type: 'auto' | 'manual';
status: 'success' | 'error' | 'cancelled';
error_message?: string;
constructor(
auth_type: AuthType,
action_type: 'auto' | 'manual',
status: 'success' | 'error' | 'cancelled',
error_message?: string,
) {
this['event.name'] = 'auth';
this['event.timestamp'] = new Date().toISOString();
this.auth_type = auth_type;
this.action_type = action_type;
this.status = status;
this.error_message = error_message;
}
}
export type TelemetryEvent = export type TelemetryEvent =
| StartSessionEvent | StartSessionEvent
| EndSessionEvent | EndSessionEvent
@@ -713,7 +746,8 @@ export type TelemetryEvent =
| ExtensionInstallEvent | ExtensionInstallEvent
| ExtensionUninstallEvent | ExtensionUninstallEvent
| ToolOutputTruncatedEvent | ToolOutputTruncatedEvent
| ModelSlashCommandEvent; | ModelSlashCommandEvent
| AuthEvent;
export class ExtensionDisableEvent implements BaseTelemetryEvent { export class ExtensionDisableEvent implements BaseTelemetryEvent {
'event.name': 'extension_disable'; 'event.name': 'extension_disable';

View File

@@ -425,7 +425,9 @@ describe('EditTool', () => {
const invocation = tool.build(params); const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal); const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toMatch(/Successfully modified file/); expect(result.llmContent).toMatch(
/Showing lines \d+-\d+ of \d+ from the edited file:/,
);
expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent); expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent);
const display = result.returnDisplay as FileDiff; const display = result.returnDisplay as FileDiff;
expect(display.fileDiff).toMatch(initialContent); expect(display.fileDiff).toMatch(initialContent);
@@ -450,6 +452,9 @@ describe('EditTool', () => {
const result = await invocation.execute(new AbortController().signal); const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toMatch(/Created new file/); expect(result.llmContent).toMatch(/Created new file/);
expect(result.llmContent).toMatch(
/Showing lines \d+-\d+ of \d+ from the edited file:/,
);
expect(fs.existsSync(newFilePath)).toBe(true); expect(fs.existsSync(newFilePath)).toBe(true);
expect(fs.readFileSync(newFilePath, 'utf8')).toBe(fileContent); expect(fs.readFileSync(newFilePath, 'utf8')).toBe(fileContent);
@@ -485,7 +490,7 @@ describe('EditTool', () => {
); );
}); });
it('should return error if multiple occurrences of old_string are found', async () => { it('should return error if multiple occurrences of old_string are found and replace_all is false', async () => {
fs.writeFileSync(filePath, 'multiple old old strings', 'utf8'); fs.writeFileSync(filePath, 'multiple old old strings', 'utf8');
const params: EditToolParams = { const params: EditToolParams = {
file_path: filePath, file_path: filePath,
@@ -494,27 +499,27 @@ describe('EditTool', () => {
}; };
const invocation = tool.build(params); const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal); const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toMatch( expect(result.llmContent).toMatch(/replace_all was not enabled/);
/Expected 1 occurrence but found 2 for old_string in file/,
);
expect(result.returnDisplay).toMatch( expect(result.returnDisplay).toMatch(
/Failed to edit, expected 1 occurrence but found 2/, /Failed to edit because the text matches multiple locations/,
); );
}); });
it('should successfully replace multiple occurrences when expected_replacements specified', async () => { it('should successfully replace multiple occurrences when replace_all is true', async () => {
fs.writeFileSync(filePath, 'old text\nold text\nold text', 'utf8'); fs.writeFileSync(filePath, 'old text\nold text\nold text', 'utf8');
const params: EditToolParams = { const params: EditToolParams = {
file_path: filePath, file_path: filePath,
old_string: 'old', old_string: 'old',
new_string: 'new', new_string: 'new',
expected_replacements: 3, replace_all: true,
}; };
const invocation = tool.build(params); const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal); const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toMatch(/Successfully modified file/); expect(result.llmContent).toMatch(
/Showing lines \d+-\d+ of \d+ from the edited file/,
);
expect(fs.readFileSync(filePath, 'utf8')).toBe( expect(fs.readFileSync(filePath, 'utf8')).toBe(
'new text\nnew text\nnew text', 'new text\nnew text\nnew text',
); );
@@ -535,24 +540,6 @@ describe('EditTool', () => {
}); });
}); });
it('should return error if expected_replacements does not match actual occurrences', async () => {
fs.writeFileSync(filePath, 'old text old text', 'utf8');
const params: EditToolParams = {
file_path: filePath,
old_string: 'old',
new_string: 'new',
expected_replacements: 3, // Expecting 3 but only 2 exist
};
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toMatch(
/Expected 3 occurrences but found 2 for old_string in file/,
);
expect(result.returnDisplay).toMatch(
/Failed to edit, expected 3 occurrences but found 2/,
);
});
it('should return error if trying to create a file that already exists (empty old_string)', async () => { it('should return error if trying to create a file that already exists (empty old_string)', async () => {
fs.writeFileSync(filePath, 'Existing content', 'utf8'); fs.writeFileSync(filePath, 'Existing content', 'utf8');
const params: EditToolParams = { const params: EditToolParams = {
@@ -568,38 +555,6 @@ describe('EditTool', () => {
); );
}); });
it('should include modification message when proposed content is modified', async () => {
const initialContent = 'Line 1\nold line\nLine 3\nLine 4\nLine 5\n';
fs.writeFileSync(filePath, initialContent, 'utf8');
const params: EditToolParams = {
file_path: filePath,
old_string: 'old',
new_string: 'new',
modified_by_user: true,
ai_proposed_content: 'Line 1\nAI line\nLine 3\nLine 4\nLine 5\n',
};
(mockConfig.getApprovalMode as Mock).mockReturnValueOnce(
ApprovalMode.AUTO_EDIT,
);
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toMatch(
/User modified the `new_string` content/,
);
expect((result.returnDisplay as FileDiff).diffStat).toStrictEqual({
model_added_lines: 1,
model_removed_lines: 1,
model_added_chars: 7,
model_removed_chars: 8,
user_added_lines: 1,
user_removed_lines: 1,
user_added_chars: 8,
user_removed_chars: 7,
});
});
it('should not include modification message when proposed content is not modified', async () => { it('should not include modification message when proposed content is not modified', async () => {
const initialContent = 'This is some old text.'; const initialContent = 'This is some old text.';
fs.writeFileSync(filePath, initialContent, 'utf8'); fs.writeFileSync(filePath, initialContent, 'utf8');
@@ -723,13 +678,12 @@ describe('EditTool', () => {
expect(result.error?.type).toBe(ToolErrorType.EDIT_NO_OCCURRENCE_FOUND); expect(result.error?.type).toBe(ToolErrorType.EDIT_NO_OCCURRENCE_FOUND);
}); });
it('should return EXPECTED_OCCURRENCE_MISMATCH error', async () => { it('should return EXPECTED_OCCURRENCE_MISMATCH error when replace_all is false and text is not unique', async () => {
fs.writeFileSync(filePath, 'one one two', 'utf8'); fs.writeFileSync(filePath, 'one one two', 'utf8');
const params: EditToolParams = { const params: EditToolParams = {
file_path: filePath, file_path: filePath,
old_string: 'one', old_string: 'one',
new_string: 'new', new_string: 'new',
expected_replacements: 3,
}; };
const invocation = tool.build(params); const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal); const result = await invocation.execute(new AbortController().signal);

View File

@@ -22,7 +22,7 @@ import type { Config } from '../config/config.js';
import { ApprovalMode } from '../config/config.js'; import { ApprovalMode } from '../config/config.js';
import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js'; import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js';
import { ReadFileTool } from './read-file.js'; import { ReadFileTool } from './read-file.js';
import { ToolNames } from './tool-names.js'; import { ToolNames, ToolDisplayNames } from './tool-names.js';
import { logFileOperation } from '../telemetry/loggers.js'; import { logFileOperation } from '../telemetry/loggers.js';
import { FileOperationEvent } from '../telemetry/types.js'; import { FileOperationEvent } from '../telemetry/types.js';
import { FileOperation } from '../telemetry/metrics.js'; import { FileOperation } from '../telemetry/metrics.js';
@@ -34,6 +34,12 @@ import type {
} from './modifiable-tool.js'; } from './modifiable-tool.js';
import { IdeClient } from '../ide/ide-client.js'; import { IdeClient } from '../ide/ide-client.js';
import { safeLiteralReplace } from '../utils/textUtils.js'; import { safeLiteralReplace } from '../utils/textUtils.js';
import {
countOccurrences,
extractEditSnippet,
maybeAugmentOldStringForDeletion,
normalizeEditStrings,
} from '../utils/editHelper.js';
export function applyReplacement( export function applyReplacement(
currentContent: string | null, currentContent: string | null,
@@ -77,10 +83,9 @@ export interface EditToolParams {
new_string: string; new_string: string;
/** /**
* Number of replacements expected. Defaults to 1 if not specified. * Replace every occurrence of old_string instead of requiring a unique match.
* Use when you want to replace multiple occurrences.
*/ */
expected_replacements?: number; replace_all?: boolean;
/** /**
* Whether the edit was modified manually by the user. * Whether the edit was modified manually by the user.
@@ -118,12 +123,12 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
* @throws File system errors if reading the file fails unexpectedly (e.g., permissions) * @throws File system errors if reading the file fails unexpectedly (e.g., permissions)
*/ */
private async calculateEdit(params: EditToolParams): Promise<CalculatedEdit> { private async calculateEdit(params: EditToolParams): Promise<CalculatedEdit> {
const expectedReplacements = params.expected_replacements ?? 1; const replaceAll = params.replace_all ?? false;
let currentContent: string | null = null; let currentContent: string | null = null;
let fileExists = false; let fileExists = false;
let isNewFile = false; let isNewFile = false;
const finalNewString = params.new_string; let finalNewString = params.new_string;
const finalOldString = params.old_string; let finalOldString = params.old_string;
let occurrences = 0; let occurrences = 0;
let error: let error:
| { display: string; raw: string; type: ToolErrorType } | { display: string; raw: string; type: ToolErrorType }
@@ -144,7 +149,15 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
fileExists = false; fileExists = false;
} }
if (params.old_string === '' && !fileExists) { const normalizedStrings = normalizeEditStrings(
currentContent,
finalOldString,
finalNewString,
);
finalOldString = normalizedStrings.oldString;
finalNewString = normalizedStrings.newString;
if (finalOldString === '' && !fileExists) {
// Creating a new file // Creating a new file
isNewFile = true; isNewFile = true;
} else if (!fileExists) { } else if (!fileExists) {
@@ -155,7 +168,13 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
type: ToolErrorType.FILE_NOT_FOUND, type: ToolErrorType.FILE_NOT_FOUND,
}; };
} else if (currentContent !== null) { } else if (currentContent !== null) {
occurrences = this.countOccurrences(currentContent, params.old_string); finalOldString = maybeAugmentOldStringForDeletion(
currentContent,
finalOldString,
finalNewString,
);
occurrences = countOccurrences(currentContent, finalOldString);
if (params.old_string === '') { if (params.old_string === '') {
// Error: Trying to create a file that already exists // Error: Trying to create a file that already exists
error = { error = {
@@ -169,13 +188,10 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
raw: `Failed to edit, 0 occurrences found for old_string in ${params.file_path}. No edits made. The exact text in old_string was not found. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use ${ReadFileTool.Name} tool to verify.`, raw: `Failed to edit, 0 occurrences found for old_string in ${params.file_path}. No edits made. The exact text in old_string was not found. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use ${ReadFileTool.Name} tool to verify.`,
type: ToolErrorType.EDIT_NO_OCCURRENCE_FOUND, type: ToolErrorType.EDIT_NO_OCCURRENCE_FOUND,
}; };
} else if (occurrences !== expectedReplacements) { } else if (!replaceAll && occurrences > 1) {
const occurrenceTerm =
expectedReplacements === 1 ? 'occurrence' : 'occurrences';
error = { error = {
display: `Failed to edit, expected ${expectedReplacements} ${occurrenceTerm} but found ${occurrences}.`, display: `Failed to edit because the text matches multiple locations. Provide more context or set replace_all to true.`,
raw: `Failed to edit, Expected ${expectedReplacements} ${occurrenceTerm} but found ${occurrences} for old_string in file: ${params.file_path}`, raw: `Failed to edit. Found ${occurrences} occurrences for old_string in ${params.file_path} but replace_all was not enabled.`,
type: ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH, type: ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH,
}; };
} else if (finalOldString === finalNewString) { } else if (finalOldString === finalNewString) {
@@ -221,22 +237,6 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
}; };
} }
/**
* Counts occurrences of a substring in a string
*/
private countOccurrences(str: string, substr: string): number {
if (substr === '') {
return 0;
}
let count = 0;
let pos = str.indexOf(substr);
while (pos !== -1) {
count++;
pos = str.indexOf(substr, pos + substr.length); // Start search after the current match
}
return count;
}
/** /**
* Handles the confirmation prompt for the Edit tool in the CLI. * Handles the confirmation prompt for the Edit tool in the CLI.
* It needs to calculate the diff to show the user. * It needs to calculate the diff to show the user.
@@ -422,12 +422,16 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
const llmSuccessMessageParts = [ const llmSuccessMessageParts = [
editData.isNewFile editData.isNewFile
? `Created new file: ${this.params.file_path} with provided content.` ? `Created new file: ${this.params.file_path} with provided content.`
: `Successfully modified file: ${this.params.file_path} (${editData.occurrences} replacements).`, : `The file: ${this.params.file_path} has been updated.`,
]; ];
if (this.params.modified_by_user) {
llmSuccessMessageParts.push( const snippetResult = extractEditSnippet(
`User modified the \`new_string\` content to be: ${this.params.new_string}.`, editData.currentContent,
); editData.newContent,
);
if (snippetResult) {
const snippetText = `Showing lines ${snippetResult.startLine}-${snippetResult.endLine} of ${snippetResult.totalLines} from the edited file:\n\n---\n\n${snippetResult.content}`;
llmSuccessMessageParts.push(snippetText);
} }
return { return {
@@ -469,8 +473,8 @@ export class EditTool
constructor(private readonly config: Config) { constructor(private readonly config: Config) {
super( super(
EditTool.Name, EditTool.Name,
'Edit', ToolDisplayNames.EDIT,
`Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${ReadFileTool.Name} tool to examine the file's current content before attempting a text replacement. `Replaces text within a file. By default, replaces a single occurrence. Set \`replace_all\` to true when you intend to modify every instance of \`old_string\`. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${ReadFileTool.Name} tool to examine the file's current content before attempting a text replacement.
The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response. The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.
@@ -480,7 +484,7 @@ Expectation for required parameters:
3. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic. 3. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic.
4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement. 4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement.
**Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail. **Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail.
**Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.`, **Multiple replacements:** Set \`replace_all\` to true when you want to replace every occurrence that matches \`old_string\`.`,
Kind.Edit, Kind.Edit,
{ {
properties: { properties: {
@@ -491,7 +495,7 @@ Expectation for required parameters:
}, },
old_string: { old_string: {
description: description:
'The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. For multiple replacements, specify expected_replacements parameter. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.', 'The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.',
type: 'string', type: 'string',
}, },
new_string: { new_string: {
@@ -499,11 +503,10 @@ Expectation for required parameters:
'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.', 'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.',
type: 'string', type: 'string',
}, },
expected_replacements: { replace_all: {
type: 'number', type: 'boolean',
description: description:
'Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.', 'Replace all occurrences of old_string (default false).',
minimum: 1,
}, },
}, },
required: ['file_path', 'old_string', 'new_string'], required: ['file_path', 'old_string', 'new_string'],

View File

@@ -14,6 +14,7 @@ import {
import type { FunctionDeclaration } from '@google/genai'; import type { FunctionDeclaration } from '@google/genai';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import { ApprovalMode } from '../config/config.js'; import { ApprovalMode } from '../config/config.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
export interface ExitPlanModeParams { export interface ExitPlanModeParams {
plan: string; plan: string;
@@ -152,12 +153,12 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
ExitPlanModeParams, ExitPlanModeParams,
ToolResult ToolResult
> { > {
static readonly Name: string = exitPlanModeToolSchemaData.name!; static readonly Name: string = ToolNames.EXIT_PLAN_MODE;
constructor(private readonly config: Config) { constructor(private readonly config: Config) {
super( super(
ExitPlanModeTool.Name, ExitPlanModeTool.Name,
'ExitPlanMode', ToolDisplayNames.EXIT_PLAN_MODE,
exitPlanModeToolDescription, exitPlanModeToolDescription,
Kind.Think, Kind.Think,
exitPlanModeToolSchemaData.parametersJsonSchema as Record< exitPlanModeToolSchemaData.parametersJsonSchema as Record<

View File

@@ -9,7 +9,7 @@ import path from 'node:path';
import { glob, escape } from 'glob'; import { glob, escape } from 'glob';
import type { ToolInvocation, ToolResult } from './tools.js'; import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames } from './tool-names.js'; import { ToolNames, ToolDisplayNames } from './tool-names.js';
import { resolveAndValidatePath } from '../utils/paths.js'; import { resolveAndValidatePath } from '../utils/paths.js';
import { type Config } from '../config/config.js'; import { type Config } from '../config/config.js';
import { import {
@@ -229,7 +229,7 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
constructor(private config: Config) { constructor(private config: Config) {
super( super(
GlobTool.Name, GlobTool.Name,
'FindFiles', ToolDisplayNames.GLOB,
'Fast file pattern matching tool that works with any codebase size\n- Supports glob patterns like "**/*.js" or "src/**/*.ts"\n- Returns matching file paths sorted by modification time\n- Use this tool when you need to find files by name patterns\n- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.', 'Fast file pattern matching tool that works with any codebase size\n- Supports glob patterns like "**/*.js" or "src/**/*.ts"\n- Returns matching file paths sorted by modification time\n- Use this tool when you need to find files by name patterns\n- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.',
Kind.Search, Kind.Search,
{ {

View File

@@ -18,19 +18,68 @@ import * as glob from 'glob';
vi.mock('glob', { spy: true }); vi.mock('glob', { spy: true });
// Mock the child_process module to control grep/git grep behavior // Mock the child_process module to control grep/git grep behavior
vi.mock('child_process', () => ({ vi.mock('child_process', async (importOriginal) => {
spawn: vi.fn(() => ({ const actual = await importOriginal<typeof import('child_process')>();
on: (event: string, cb: (...args: unknown[]) => void) => { return {
if (event === 'error' || event === 'close') { ...actual,
// Simulate command not found or error for git grep and system grep spawn: vi.fn(() => {
// to force it to fall back to JS implementation. // Create a proper mock EventEmitter-like child process
setTimeout(() => cb(1), 0); // cb(1) for error/close const listeners: Map<
} string,
}, Set<(...args: unknown[]) => void>
stdout: { on: vi.fn() }, > = new Map();
stderr: { on: vi.fn() },
})), const createStream = () => ({
})); on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
const key = `stream:${event}`;
if (!listeners.has(key)) listeners.set(key, new Set());
listeners.get(key)!.add(cb);
}),
removeListener: vi.fn(
(event: string, cb: (...args: unknown[]) => void) => {
const key = `stream:${event}`;
listeners.get(key)?.delete(cb);
},
),
});
return {
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
const key = `child:${event}`;
if (!listeners.has(key)) listeners.set(key, new Set());
listeners.get(key)!.add(cb);
// Simulate command not found or error for git grep and system grep
// to force it to fall back to JS implementation.
if (event === 'error') {
setTimeout(() => cb(new Error('Command not found')), 0);
} else if (event === 'close') {
setTimeout(() => cb(1), 0); // Exit code 1 for error
}
}),
removeListener: vi.fn(
(event: string, cb: (...args: unknown[]) => void) => {
const key = `child:${event}`;
listeners.get(key)?.delete(cb);
},
),
stdout: createStream(),
stderr: createStream(),
connected: false,
disconnect: vi.fn(),
};
}),
exec: vi.fn(
(
cmd: string,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
// Mock exec to fail for git grep commands
callback(new Error('Command not found'), '', '');
},
),
};
});
describe('GrepTool', () => { describe('GrepTool', () => {
let tempRootDir: string; let tempRootDir: string;

View File

@@ -11,13 +11,14 @@ import { spawn } from 'node:child_process';
import { globStream } from 'glob'; import { globStream } from 'glob';
import type { ToolInvocation, ToolResult } from './tools.js'; import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames } from './tool-names.js'; import { ToolNames, ToolDisplayNames } from './tool-names.js';
import { resolveAndValidatePath } from '../utils/paths.js'; import { resolveAndValidatePath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js';
import { isGitRepository } from '../utils/gitUtils.js'; import { isGitRepository } from '../utils/gitUtils.js';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import type { FileExclusions } from '../utils/ignorePatterns.js'; import type { FileExclusions } from '../utils/ignorePatterns.js';
import { ToolErrorType } from './tool-error.js'; import { ToolErrorType } from './tool-error.js';
import { isCommandAvailable } from '../utils/shell-utils.js';
// --- Interfaces --- // --- Interfaces ---
@@ -195,29 +196,6 @@ class GrepToolInvocation extends BaseToolInvocation<
} }
} }
/**
* Checks if a command is available in the system's PATH.
* @param {string} command The command name (e.g., 'git', 'grep').
* @returns {Promise<boolean>} True if the command is available, false otherwise.
*/
private isCommandAvailable(command: string): Promise<boolean> {
return new Promise((resolve) => {
const checkCommand = process.platform === 'win32' ? 'where' : 'command';
const checkArgs =
process.platform === 'win32' ? [command] : ['-v', command];
try {
const child = spawn(checkCommand, checkArgs, {
stdio: 'ignore',
shell: process.platform === 'win32',
});
child.on('close', (code) => resolve(code === 0));
child.on('error', () => resolve(false));
} catch {
resolve(false);
}
});
}
/** /**
* Parses the standard output of grep-like commands (git grep, system grep). * Parses the standard output of grep-like commands (git grep, system grep).
* Expects format: filePath:lineNumber:lineContent * Expects format: filePath:lineNumber:lineContent
@@ -297,7 +275,7 @@ class GrepToolInvocation extends BaseToolInvocation<
try { try {
// --- Strategy 1: git grep --- // --- Strategy 1: git grep ---
const isGit = isGitRepository(absolutePath); const isGit = isGitRepository(absolutePath);
const gitAvailable = isGit && (await this.isCommandAvailable('git')); const gitAvailable = isGit && isCommandAvailable('git').available;
if (gitAvailable) { if (gitAvailable) {
strategyUsed = 'git grep'; strategyUsed = 'git grep';
@@ -350,7 +328,7 @@ class GrepToolInvocation extends BaseToolInvocation<
} }
// --- Strategy 2: System grep --- // --- Strategy 2: System grep ---
const grepAvailable = await this.isCommandAvailable('grep'); const { available: grepAvailable } = isCommandAvailable('grep');
if (grepAvailable) { if (grepAvailable) {
strategyUsed = 'system grep'; strategyUsed = 'system grep';
const grepArgs = ['-r', '-n', '-H', '-E']; const grepArgs = ['-r', '-n', '-H', '-E'];
@@ -522,7 +500,7 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
constructor(private readonly config: Config) { constructor(private readonly config: Config) {
super( super(
GrepTool.Name, GrepTool.Name,
'Grep', ToolDisplayNames.GREP,
'A powerful search tool for finding patterns in files\n\n Usage:\n - ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access.\n - Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")\n - Filter files with glob parameter (e.g., "*.js", "**/*.tsx")\n - Case-insensitive by default\n - Use Task tool for open-ended searches requiring multiple rounds\n', 'A powerful search tool for finding patterns in files\n\n Usage:\n - ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access.\n - Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")\n - Filter files with glob parameter (e.g., "*.js", "**/*.tsx")\n - Case-insensitive by default\n - Use Task tool for open-ended searches requiring multiple rounds\n',
Kind.Search, Kind.Search,
{ {

View File

@@ -12,6 +12,7 @@ import { makeRelative, shortenPath } from '../utils/paths.js';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
import { ToolErrorType } from './tool-error.js'; import { ToolErrorType } from './tool-error.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
/** /**
* Parameters for the LS tool * Parameters for the LS tool
@@ -252,12 +253,12 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
* Implementation of the LS tool logic * Implementation of the LS tool logic
*/ */
export class LSTool extends BaseDeclarativeTool<LSToolParams, ToolResult> { export class LSTool extends BaseDeclarativeTool<LSToolParams, ToolResult> {
static readonly Name = 'list_directory'; static readonly Name = ToolNames.LS;
constructor(private config: Config) { constructor(private config: Config) {
super( super(
LSTool.Name, LSTool.Name,
'ReadFolder', ToolDisplayNames.LS,
'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.', 'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.',
Kind.Search, Kind.Search,
{ {

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