Merge branch 'main' of github.com:QwenLM/qwen-code into fix/windows-startup-and-exit-hangs

This commit is contained in:
xuewenjie
2025-12-18 13:24:21 +08:00
64 changed files with 2447 additions and 645 deletions

View File

@@ -627,7 +627,12 @@ The MCP integration tracks several states:
### Schema Compatibility
- **Property stripping:** The system automatically removes certain schema properties (`$schema`, `additionalProperties`) for Qwen API compatibility
- **Schema compliance mode:** By default (`schemaCompliance: "auto"`), tool schemas are passed through as-is. Set `"model": { "generationConfig": { "schemaCompliance": "openapi_30" } }` in your `settings.json` to convert models to Strict OpenAPI 3.0 format.
- **OpenAPI 3.0 transformations:** When `openapi_30` mode is enabled, the system handles:
- Nullable types: `["string", "null"]` -> `type: "string", nullable: true`
- Const values: `const: "foo"` -> `enum: ["foo"]`
- Exclusive limits: numeric `exclusiveMinimum` -> boolean form with `minimum`
- Keyword removal: `$schema`, `$id`, `dependencies`, `patternProperties`
- **Name sanitization:** Tool names are automatically sanitized to meet API requirements
- **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing

View File

@@ -5,7 +5,6 @@ Welcome to the Qwen Code documentation. Qwen Code is an agentic coding tool that
## Documentation Sections
### [User Guide](./users/overview)
Learn how to use Qwen Code as an end user. This section covers:
- Basic installation and setup
@@ -14,7 +13,7 @@ Learn how to use Qwen Code as an end user. This section covers:
- Configuration options
- Troubleshooting
### [Developer Guide](./developers/contributing)
### [Developer Guide](./developers/architecture)
Learn how to contribute to and develop Qwen Code. This section covers:

View File

@@ -189,8 +189,8 @@ Then select "create" and follow the prompts to define:
> - Create project-specific subagents in `.qwen/agents/` for team sharing
> - Use descriptive `description` fields to enable automatic delegation
> - Limit tool access to what each subagent actually needs
> - Know more about [Sub Agents](/users/features/sub-agents)
> - Know more about [Approval Mode](/users/features/approval-mode)
> - Know more about [Sub Agents](./features/sub-agents)
> - Know more about [Approval Mode](./features/approval-mode)
## Work with tests
@@ -318,7 +318,7 @@ This provides a directory listing with file information.
Show me the data from @github: repos/owner/repo/issues
```
This fetches data from connected MCP servers using the format @server: resource. See [MCP](/users/features/mcp) for details.
This fetches data from connected MCP servers using the format @server: resource. See [MCP](./features/mcp) for details.
> [!tip]
>

View File

@@ -6,7 +6,7 @@ Qwen Code includes the ability to automatically ignore files, similar to `.gitig
## How it works
When you add a path to your `.qwenignore` file, tools that respect this file will exclude matching files and directories from their operations. For example, when you use the [`read_many_files`](/developers/tools/multi-file) command, any paths in your `.qwenignore` file will be automatically excluded.
When you add a path to your `.qwenignore` file, tools that respect this file will exclude matching files and directories from their operations. For example, when you use the [`read_many_files`](../../developers/tools/multi-file) command, any paths in your `.qwenignore` file will be automatically excluded.
For the most part, `.qwenignore` follows the conventions of `.gitignore` files:
@@ -20,9 +20,9 @@ You can update your `.qwenignore` file at any time. To apply the changes, you mu
## How to use `.qwenignore`
| Step | Description |
| ---------------------- | ------------------------------------------------------------ |
| **Enable .qwenignore** | Create a file named `.qwenignore` in your project root directory |
| Step | Description |
| ---------------------- | -------------------------------------------------------------------------------------- |
| **Enable .qwenignore** | Create a file named `.qwenignore` in your project root directory |
| **Add ignore rules** | Open `.qwenignore` file and add paths to ignore, example: `/archive/` or `apikeys.txt` |
### `.qwenignore` examples

View File

@@ -2,7 +2,7 @@
> [!tip]
>
> **Authentication / API keys:** Authentication (Qwen OAuth vs OpenAI-compatible API) and auth-related environment variables (like `OPENAI_API_KEY`) are documented in **[Authentication](/users/configuration/auth)**.
> **Authentication / API keys:** Authentication (Qwen OAuth vs OpenAI-compatible API) and auth-related environment variables (like `OPENAI_API_KEY`) are documented in **[Authentication](../configuration/auth)**.
> [!note]
>
@@ -42,7 +42,7 @@ Qwen Code uses JSON settings files for persistent configuration. There are four
In addition to a project settings file, a project's `.qwen` directory can contain other project-specific files related to Qwen Code's operation, such as:
- [Custom sandbox profiles](/users/features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`).
- [Custom sandbox profiles](../features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`).
### Available settings in `settings.json`
@@ -50,13 +50,14 @@ Settings are organized into categories. All settings should be placed within the
#### general
| Setting | Type | Description | Default |
| ------------------------------- | ------- | ------------------------------------------ | ----------- |
| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` |
| `general.vimMode` | boolean | Enable Vim keybindings. | `false` |
| `general.disableAutoUpdate` | boolean | Disable automatic updates. | `false` |
| `general.disableUpdateNag` | boolean | Disable update notification prompts. | `false` |
| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` |
| Setting | Type | Description | Default |
| ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | ----------- |
| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` |
| `general.vimMode` | boolean | Enable Vim keybindings. | `false` |
| `general.disableAutoUpdate` | boolean | Disable automatic updates. | `false` |
| `general.disableUpdateNag` | boolean | Disable update notification prompts. | `false` |
| `general.gitCoAuthor` | boolean | Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. | `true` |
| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` |
#### output
@@ -68,7 +69,7 @@ Settings are organized into categories. All settings should be placed within the
| Setting | Type | Description | Default |
| ---------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `ui.theme` | string | The color theme for the UI. See [Themes](/users/configuration/themes) for available options. | `undefined` |
| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` |
| `ui.customThemes` | object | Custom theme definitions. | `{}` |
| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` |
| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` |
@@ -325,7 +326,7 @@ The CLI keeps a history of shell commands you run. To avoid conflicts between di
Environment variables are a common way to configure applications, especially for sensitive information (like tokens) or for settings that might change between environments.
Qwen Code can automatically load environment variables from `.env` files.
For authentication-related variables (like `OPENAI_*`) and the recommended `.qwen/.env` approach, see **[Authentication](/users/configuration/auth)**.
For authentication-related variables (like `OPENAI_*`) and the recommended `.qwen/.env` approach, see **[Authentication](../configuration/auth)**.
> [!tip]
>
@@ -361,9 +362,9 @@ Arguments passed directly when running the CLI can override other configurations
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](/users/features/headless) for detailed information. |
| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](/users/features/headless) for detailed information. |
| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](/users/features/headless) for detailed information about stream events. |
| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. |
| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. |
| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. |
| `--sandbox` | `-s` | Enables sandbox mode for this session. | | |
| `--sandbox-image` | | Sets the sandbox image URI. | | |
| `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | |
@@ -371,14 +372,14 @@ Arguments passed directly when running the CLI can override other configurations
| `--help` | `-h` | Displays help information about command-line arguments. | | |
| `--show-memory-usage` | | Displays the current memory usage. | | |
| `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | |
| `--approval-mode` | | Sets the approval mode for tool calls. | `plan`, `default`, `auto-edit`, `yolo` | Supported modes: `plan`: Analyze only—do not modify files or execute commands. `default`: Require approval for file edits or shell commands (default behavior). `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others. `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. Example: `qwen --approval-mode auto-edit`<br>See more about [Approval Mode](/users/features/approval-mode). |
| `--approval-mode` | | Sets the approval mode for tool calls. | `plan`, `default`, `auto-edit`, `yolo` | Supported modes: `plan`: Analyze only—do not modify files or execute commands. `default`: Require approval for file edits or shell commands (default behavior). `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others. `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. Example: `qwen --approval-mode auto-edit`<br>See more about [Approval Mode](../features/approval-mode). |
| `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` |
| `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | |
| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. |
| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](/developers/development/telemetry) for more information. |
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](/developers/development/telemetry) for more information. |
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](/developers/development/telemetry) for more information. |
| `--checkpointing` | | Enables [checkpointing](/users/features/checkpointing). | | |
| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
@@ -437,11 +438,11 @@ This example demonstrates how you can provide general project context, specific
- Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with the `context.discoveryMaxDirs` setting in your `settings.json` file.
- Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project.
- **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context.
- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](/users/configuration/memory).
- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../configuration/memory).
- **Commands for Memory Management:**
- Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context.
- Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI.
- See the [Commands documentation](/users/reference/cli-reference) for full details on the `/memory` command and its sub-commands (`show` and `refresh`).
- See the [Commands documentation](../features/commands) for full details on the `/memory` command and its sub-commands (`show` and `refresh`).
By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor Qwen Code's responses to your specific needs and projects.
@@ -449,7 +450,7 @@ By understanding and utilizing these configuration layers and the hierarchical n
Qwen Code can execute potentially unsafe operations (like shell commands and file modifications) within a sandboxed environment to protect your system.
[Sandbox](/users/features/sandbox) is disabled by default, but you can enable it in a few ways:
[Sandbox](../features/sandbox) is disabled by default, but you can enable it in a few ways:
- Using `--sandbox` or `-s` flag.
- Setting `GEMINI_SANDBOX` environment variable.

View File

@@ -32,7 +32,7 @@ Qwen Code comes with a selection of pre-defined themes, which you can list using
### Theme Persistence
Selected themes are saved in Qwen Code's [configuration](./configuration.md) so your preference is remembered across sessions.
Selected themes are saved in Qwen Code's [configuration](../configuration/settings) so your preference is remembered across sessions.
---
@@ -140,25 +140,21 @@ The theme file must be a valid JSON file that follows the same structure as a cu
### Example Custom Theme
<img src="https://gw.alicdn.com/imgextra/i1/O1CN01Em30Hc1jYXAdIgls3_!!6000000004560-2-tps-1009-629.png" alt=" " style="zoom:100%;text-align:center;margin: 0 auto;" />
### Using Your Custom Theme
- Select your custom theme using the `/theme` command in Qwen Code. Your custom theme will appear in the theme selection dialog.
- Or, set it as the default by adding `"theme": "MyCustomTheme"` to the `ui` object in your `settings.json`.
- Custom themes can be set at the user, project, or system level, and follow the same [configuration precedence](./configuration.md) as other settings.
- Custom themes can be set at the user, project, or system level, and follow the same [configuration precedence](../configuration/settings) as other settings.
## Themes Preview
| Dark Theme | Preview | Light Theme | Preview |
| :-: | :-: | :-: | :-: |
| ANSI | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01ZInJiq1GdSZc9gHsI_!!6000000000645-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | ANSI Light | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01IiJQFC1h9E3MXQj6W_!!6000000004234-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Atom OneDark | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01Zlx1SO1Sw21SkTKV3_!!6000000002310-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | Ayu Light | <img src="https://gw.alicdn.com/imgextra/i3/O1CN01zEUc1V1jeUJsnCgQb_!!6000000004573-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Ayu | <img src="https://gw.alicdn.com/imgextra/i3/O1CN019upo6v1SmPhmRjzfN_!!6000000002289-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> | Default Light | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01RHjrEs1u7TXq3M6l3_!!6000000005990-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Default | <img src="https://gw.alicdn.com/imgextra/i4/O1CN016pIeXz1pFC8owmR4Q_!!6000000005330-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | GitHub Light | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01US2b0g1VETCPAVWLA_!!6000000002621-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Dracula | <img src="https://gw.alicdn.com/imgextra/i4/O1CN016htnWH20c3gd2LpUR_!!6000000006869-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | Google Code | <img src="https://gw.alicdn.com/imgextra/i1/O1CN01Ng29ab23iQ2BuYKz8_!!6000000007289-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| GitHub | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01fFCRda1IQIQ9qDNqv_!!6000000000887-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> | Xcode | <img src="https://gw.alicdn.com/imgextra/i1/O1CN010E3QAi1Huh5o1E9LN_!!6000000000818-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Dark Theme | Preview | Light Theme | Preview |
| :----------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| ANSI | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01ZInJiq1GdSZc9gHsI_!!6000000000645-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | ANSI Light | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01IiJQFC1h9E3MXQj6W_!!6000000004234-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Atom OneDark | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01Zlx1SO1Sw21SkTKV3_!!6000000002310-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | Ayu Light | <img src="https://gw.alicdn.com/imgextra/i3/O1CN01zEUc1V1jeUJsnCgQb_!!6000000004573-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Ayu | <img src="https://gw.alicdn.com/imgextra/i3/O1CN019upo6v1SmPhmRjzfN_!!6000000002289-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> | Default Light | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01RHjrEs1u7TXq3M6l3_!!6000000005990-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Default | <img src="https://gw.alicdn.com/imgextra/i4/O1CN016pIeXz1pFC8owmR4Q_!!6000000005330-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | GitHub Light | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01US2b0g1VETCPAVWLA_!!6000000002621-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Dracula | <img src="https://gw.alicdn.com/imgextra/i4/O1CN016htnWH20c3gd2LpUR_!!6000000006869-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | Google Code | <img src="https://gw.alicdn.com/imgextra/i1/O1CN01Ng29ab23iQ2BuYKz8_!!6000000007289-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| GitHub | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01fFCRda1IQIQ9qDNqv_!!6000000000887-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> | Xcode | <img src="https://gw.alicdn.com/imgextra/i1/O1CN010E3QAi1Huh5o1E9LN_!!6000000000818-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |

View File

@@ -56,6 +56,6 @@ If you need to change a decision or see all your settings, you have a couple of
For advanced users, it's helpful to know the exact order of operations for how trust is determined:
1. **IDE Trust Signal**: If you are using the [IDE Integration](/users/ide-integration/ide-integration), the CLI first asks the IDE if the workspace is trusted. The IDE's response takes highest priority.
1. **IDE Trust Signal**: If you are using the [IDE Integration](../ide-integration/ide-integration), the CLI first asks the IDE if the workspace is trusted. The IDE's response takes highest priority.
2. **Local Trust File**: If the IDE is not connected, the CLI checks the central `~/.qwen/trustedFolders.json` file.

View File

@@ -1,3 +1,5 @@
# Approval Mode
Qwen Code offers three distinct permission modes that allow you to flexibly control how AI interacts with your code and system based on task complexity and risk level.
## Permission Modes Comparison

View File

@@ -20,10 +20,11 @@ These commands help you save, restore, and summarize work progress.
| Command | Description | Usage Examples |
| ----------- | --------------------------------------------------------- | ------------------------------------ |
| `/init` | Analyze current directory and create initial context file | `/init` |
| `/summary` | Generate project summary based on conversation history | `/summary` |
| `/compress` | Replace chat history with summary to save Tokens | `/compress` |
| `/resume` | Resume a previous conversation session | `/resume` |
| `/restore` | Restore files to state before tool execution | `/restore` (list) or `/restore <ID>` |
| `/init` | Analyze current directory and create initial context file | `/init` |
### 1.2 Interface and Workspace Control

View File

@@ -203,7 +203,7 @@ Key command-line options for headless usage:
| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` |
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](/users/configuration/settings).
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](../configuration/settings).
## Examples
@@ -276,7 +276,7 @@ tail -5 usage.log
## Resources
- [CLI Configuration](/users/configuration/settings#command-line-arguments) - Complete configuration guide
- [Authentication](/users/configuration/settings#environment-variables-for-api-access) - Setup authentication
- [Commands](/users/reference/cli-reference) - Interactive commands reference
- [Tutorials](/users/quickstart) - Step-by-step automation guides
- [CLI Configuration](../configuration/settings#command-line-arguments) - Complete configuration guide
- [Authentication](../configuration/settings#environment-variables-for-api-access) - Setup authentication
- [Commands](../features/commands) - Interactive commands reference
- [Tutorials](../quickstart) - Step-by-step automation guides

View File

@@ -12,6 +12,7 @@ With MCP servers connected, you can ask Qwen Code to:
- Automate workflows (repeatable tasks exposed as tools/prompts)
> [!tip]
>
> If youre looking for the “one command to get started”, jump to [Quick start](#quick-start).
## Quick start
@@ -51,7 +52,8 @@ qwen mcp add --scope user --transport http my-server http://localhost:3000/mcp
```
> [!tip]
> For advanced configuration layers (system defaults/system settings and precedence rules), see [Settings](/users/configuration/settings).
>
> For advanced configuration layers (system defaults/system settings and precedence rules), see [Settings](../configuration/settings).
## Configure servers
@@ -64,6 +66,7 @@ qwen mcp add --scope user --transport http my-server http://localhost:3000/mcp
| `stdio` | Local process (scripts, CLIs, Docker) on your machine | `command`, `args` (+ optional `cwd`, `env`) |
> [!note]
>
> If a server supports both, prefer **HTTP** over **SSE**.
### Configure via `settings.json` vs `qwen mcp add`

View File

@@ -220,6 +220,6 @@ qwen -s -p "run shell command: mount | grep workspace"
## Related documentation
- [Configuration](/users/configuration/settings): Full configuration options.
- [Commands](/users/reference/cli-reference): Available commands.
- [Troubleshooting](/users/support/troubleshooting): General troubleshooting.
- [Configuration](../configuration/settings): Full configuration options.
- [Commands](../features/commands): Available commands.
- [Troubleshooting](../support/troubleshooting): General troubleshooting.

View File

@@ -2,7 +2,7 @@
Qwen Code can integrate with your IDE to provide a more seamless and context-aware experience. This integration allows the CLI to understand your workspace better and enables powerful features like native in-editor diffing.
Currently, the only supported IDE is [Visual Studio Code](https://code.visualstudio.com/) and other editors that support VS Code extensions. To build support for other editors, see the [IDE Companion Extension Spec](/users/ide-integration/ide-companion-spec).
Currently, the only supported IDE is [Visual Studio Code](https://code.visualstudio.com/) and other editors that support VS Code extensions. To build support for other editors, see the [IDE Companion Extension Spec](../ide-integration/ide-companion-spec).
## Features

View File

@@ -6,41 +6,14 @@
Use it to perform GitHub pull request reviews, triage issues, perform code analysis and modification, and more using [Qwen Code] conversationally (e.g., `@qwencoder fix this issue`) directly inside your GitHub repositories.
- [qwen-code-action](#qwen-code-action)
- [Overview](#overview)
- [Features](#features)
- [Quick Start](#quick-start)
- [1. Get a Qwen API Key](#1-get-a-qwen-api-key)
- [2. Add it as a GitHub Secret](#2-add-it-as-a-github-secret)
- [3. Update your .gitignore](#3-update-your-gitignore)
- [4. Choose a Workflow](#4-choose-a-workflow)
- [5. Try it out](#5-try-it-out)
- [Workflows](#workflows)
- [Qwen Code Dispatch](#qwen-code-dispatch)
- [Issue Triage](#issue-triage)
- [Pull Request Review](#pull-request-review)
- [Qwen Code CLI Assistant](#qwen-code-cli-assistant)
- [Configuration](#configuration)
- [Inputs](#inputs)
- [Outputs](#outputs)
- [Repository Variables](#repository-variables)
- [Secrets](#secrets)
- [Authentication](#authentication)
- [GitHub Authentication](#github-authentication)
- [Extensions](#extensions)
- [Best Practices](#best-practices)
- [Customization](#customization)
- [Contributing](#contributing)
## Features
- **Automation**: Trigger workflows based on events (e.g. issue opening) or schedules (e.g. nightly).
- **On-demand Collaboration**: Trigger workflows in issue and pull request
comments by mentioning the [Qwen Code CLI] (e.g., `@qwencoder /review`).
- **Extensible with Tools**: Leverage [Qwen Code] models' tool-calling capabilities to
interact with other CLIs like the [GitHub CLI] (`gh`).
comments by mentioning the [Qwen Code CLI](./features/commands) (e.g., `@qwencoder /review`).
- **Extensible with Tools**: Leverage [Qwen Code](../developers/tools/introduction.md) models' tool-calling capabilities to interact with other CLIs like the [GitHub CLI] (`gh`).
- **Customizable**: Use a `QWEN.md` file in your repository to provide
project-specific instructions and context to [Qwen Code CLI].
project-specific instructions and context to [Qwen Code CLI](./features/commands).
## Quick Start
@@ -48,7 +21,7 @@ Get started with Qwen Code CLI in your repository in just a few minutes:
### 1. Get a Qwen API Key
Obtain your API key from [DashScope] (Alibaba Cloud's AI platform)
Obtain your API key from [DashScope](https://help.aliyun.com/zh/model-studio/qwen-code) (Alibaba Cloud's AI platform)
### 2. Add it as a GitHub Secret
@@ -90,7 +63,7 @@ You have two options to set up a workflow:
**Option B: Manually copy workflows**
1. Copy the pre-built workflows from the [`examples/workflows`](./examples/workflows) directory to your repository's `.github/workflows` directory. Note: the `qwen-dispatch.yml` workflow must also be copied, which triggers the workflows to run.
1. Copy the pre-built workflows from the [`examples/workflows`](./common-workflow) directory to your repository's `.github/workflows` directory. Note: the `qwen-dispatch.yml` workflow must also be copied, which triggers the workflows to run.
### 5. Try it out
@@ -119,30 +92,19 @@ This action provides several pre-built workflows for different use cases. Each w
### Qwen Code Dispatch
This workflow acts as a central dispatcher for Qwen Code CLI, routing requests to
the appropriate workflow based on the triggering event and the command provided
in the comment. For a detailed guide on how to set up the dispatch workflow, go
to the
[Qwen Code Dispatch workflow documentation](./examples/workflows/qwen-dispatch).
This workflow acts as a central dispatcher for Qwen Code CLI, routing requests to the appropriate workflow based on the triggering event and the command provided in the comment. For a detailed guide on how to set up the dispatch workflow, go to the [Qwen Code Dispatch workflow documentation](./common-workflow).
### Issue Triage
This action can be used to triage GitHub Issues automatically or on a schedule.
For a detailed guide on how to set up the issue triage system, go to the
[GitHub Issue Triage workflow documentation](./examples/workflows/issue-triage).
This action can be used to triage GitHub Issues automatically or on a schedule. For a detailed guide on how to set up the issue triage system, go to the [GitHub Issue Triage workflow documentation](./examples/workflows/issue-triage).
### Pull Request Review
This action can be used to automatically review pull requests when they are
opened. For a detailed guide on how to set up the pull request review system,
go to the [GitHub PR Review workflow documentation](./examples/workflows/pr-review).
This action can be used to automatically review pull requests when they are opened. For a detailed guide on how to set up the pull request review system, go to the [GitHub PR Review workflow documentation](./common-workflow).
### Qwen Code CLI Assistant
This type of action can be used to invoke a general-purpose, conversational Qwen Code
AI assistant within the pull requests and issues to perform a wide range of
tasks. For a detailed guide on how to set up the general-purpose Qwen Code CLI workflow,
go to the [Qwen Code Assistant workflow documentation](./examples/workflows/qwen-assistant).
This type of action can be used to invoke a general-purpose, conversational Qwen Code AI assistant within the pull requests and issues to perform a wide range of tasks. For a detailed guide on how to set up the general-purpose Qwen Code CLI workflow, go to the [Qwen Code Assistant workflow documentation](./common-workflow).
## Configuration
@@ -222,8 +184,7 @@ To add a secret:
2. Enter the secret name and value.
3. Save.
For more information, refer to the
[official GitHub documentation on creating and using encrypted secrets][secrets].
For more information, refer to the [official GitHub documentation on creating and using encrypted secrets][secrets].
## Authentication
@@ -239,7 +200,7 @@ You can authenticate with GitHub in two ways:
authentication, we recommend creating a custom GitHub App.
For detailed setup instructions for both Qwen and GitHub authentication, go to the
[**Authentication documentation**](./docs/authentication.md).
[**Authentication documentation**](./configuration/auth).
## Extensions
@@ -247,7 +208,7 @@ The Qwen Code CLI can be extended with additional functionality through extensio
These extensions are installed from source from their GitHub repositories.
For detailed instructions on how to set up and configure extensions, go to the
[Extensions documentation](./docs/extensions.md).
[Extensions documentation](../developers/extensions/extension).
## Best Practices
@@ -258,20 +219,18 @@ Key recommendations include:
- **Securing Your Repository:** Implementing branch and tag protection, and restricting pull request approvers.
- **Monitoring and Auditing:** Regularly reviewing action logs and enabling OpenTelemetry for deeper insights into performance and behavior.
For a comprehensive guide on securing your repository and workflows, please refer to our [**Best Practices documentation**](./docs/best-practices.md).
For a comprehensive guide on securing your repository and workflows, please refer to our [**Best Practices documentation**](./common-workflow).
## Customization
Create a [QWEN.md] file in the root of your repository to provide
project-specific context and instructions to [Qwen Code CLI]. This is useful for defining
Create a QWEN.md file in the root of your repository to provide
project-specific context and instructions to [Qwen Code CLI](./common-workflow). This is useful for defining
coding conventions, architectural patterns, or other guidelines the model should
follow for a given repository.
## Contributing
Contributions are welcome! Check out the Qwen Code CLI
[**Contributing Guide**](./CONTRIBUTING.md) for more details on how to get
started.
Contributions are welcome! Check out the Qwen Code CLI **Contributing Guide** for more details on how to get started.
[secrets]: https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions
[Qwen Code]: https://github.com/QwenLM/qwen-code

View File

@@ -4,7 +4,7 @@
<br/>
<video src="https://cloud.video.taobao.com/vod/JnvYMhUia2EKFAaiuErqNpzWE9mz3odG76vArAHNg94.mp4" controls width="800">
<video src="https://cloud.video.taobao.com/vod/IKKwfM-kqNI3OJjM_U8uMCSMAoeEcJhs6VNCQmZxUfk.mp4" controls width="800">
Your browser does not support the video tag.
</video>

View File

@@ -7,7 +7,7 @@
### Features
- **Native agent experience**: Integrated AI assistant panel within Zed's interface
- **Agent Control Protocol**: Full support for ACP enabling advanced IDE interactions
- **Agent Client Protocol**: Full support for ACP enabling advanced IDE interactions
- **File management**: @-mention files to add them to the conversation context
- **Conversation history**: Access to past conversations within Zed

View File

@@ -36,13 +36,13 @@ Select **Qwen OAuth (Free)** authentication and follow the prompts to log in. Th
what does this project do?
```
![](https://gw.alicdn.com/imgextra/i2/O1CN01XoPbZm1CrsZzvMQ6m_!!6000000000135-1-tps-772-646.gif)
![](https://cloud.video.taobao.com/vod/j7-QtQScn8UEAaEdiv619fSkk5p-t17orpDbSqKVL5A.mp4)
You'll be prompted to log in on first use. That's it! [Continue with Quickstart (5 mins) →](/users/quickstart)
You'll be prompted to log in on first use. That's it! [Continue with Quickstart (5 mins) →](./quickstart)
> [!tip]
>
> See [troubleshooting](/users/support/troubleshooting) if you hit issues.
> See [troubleshooting](./support/troubleshooting) if you hit issues.
> [!note]
>
@@ -52,11 +52,11 @@ You'll be prompted to log in on first use. That's it! [Continue with Quickstart
- **Build features from descriptions**: Tell Qwen Code what you want to build in plain language. It will make a plan, write the code, and ensure it works.
- **Debug and fix issues**: Describe a bug or paste an error message. Qwen Code will analyze your codebase, identify the problem, and implement a fix.
- **Navigate any codebase**: Ask anything about your team's codebase, and get a thoughtful answer back. Qwen Code maintains awareness of your entire project structure, can find up-to-date information from the web, and with [MCP](/users/features/mcp) can pull from external datasources like Google Drive, Figma, and Slack.
- **Navigate any codebase**: Ask anything about your team's codebase, and get a thoughtful answer back. Qwen Code maintains awareness of your entire project structure, can find up-to-date information from the web, and with [MCP](./features/mcp) can pull from external datasources like Google Drive, Figma, and Slack.
- **Automate tedious tasks**: Fix fiddly lint issues, resolve merge conflicts, and write release notes. Do all this in a single command from your developer machines, or automatically in CI.
## Why developers love Qwen Code
- **Works in your terminal**: Not another chat window. Not another IDE. Qwen Code meets you where you already work, with the tools you already love.
- **Takes action**: Qwen Code can directly edit files, run commands, and create commits. Need more? [MCP](/users/features/mcp) lets Qwen Code read your design docs in Google Drive, update your tickets in Jira, or use _your_ custom developer tooling.
- **Takes action**: Qwen Code can directly edit files, run commands, and create commits. Need more? [MCP](./features/mcp) lets Qwen Code read your design docs in Google Drive, update your tickets in Jira, or use _your_ custom developer tooling.
- **Unix philosophy**: Qwen Code is composable and scriptable. `tail -f app.log | qwen -p "Slack me if you see any anomalies appear in this log stream"` _works_. Your CI can run `qwen -p "If there are new text strings, translate them into French and raise a PR for @lang-fr-team to review"`.

View File

@@ -206,7 +206,7 @@ Here are the most important commands for daily use:
| → `output [language]` | Set LLM output language | `/language output Chinese` |
| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` |
See the [CLI reference](/users/reference/cli-reference) for a complete list of commands.
See the [CLI reference](./features/commands) for a complete list of commands.
## Pro tips for beginners
@@ -225,9 +225,9 @@ See the [CLI reference](/users/reference/cli-reference) for a complete list of c
3. build a webpage that allows users to see and edit their information
```
**Let Claude explore first**
**Let Qwen Code explore first**
- Before making changes, let Claude understand your code:
- Before making changes, let Qwen Code understand your code:
```
analyze the database schema

View File

@@ -23,7 +23,7 @@ When you authenticate using your qwen.ai account, these Terms of Service and Pri
- **Terms of Service:** Your use is governed by the [Qwen Terms of Service](https://qwen.ai/termsservice).
- **Privacy Notice:** The collection and use of your data is described in the [Qwen Privacy Policy](https://qwen.ai/privacypolicy).
For details about authentication setup, quotas, and supported features, see [Authentication Setup](/users/configuration/settings).
For details about authentication setup, quotas, and supported features, see [Authentication Setup](../configuration/settings).
## 2. If you are using OpenAI-Compatible API Authentication
@@ -37,7 +37,7 @@ Qwen Code supports various OpenAI-compatible providers. Please refer to your spe
## Usage Statistics and Telemetry
Qwen Code may collect anonymous usage statistics and [telemetry](/developers/development/telemetry) data to improve the user experience and product quality. This data collection is optional and can be controlled through configuration settings.
Qwen Code may collect anonymous usage statistics and [telemetry](../../developers/development/telemetry) data to improve the user experience and product quality. This data collection is optional and can be controlled through configuration settings.
### What Data is Collected
@@ -91,4 +91,4 @@ You can switch between Qwen OAuth and OpenAI-compatible API authentication at an
2. **Within the CLI**: Use the `/auth` command to reconfigure your authentication method
3. **Environment variables**: Set up `.env` files for automatic OpenAI-compatible API authentication
For detailed instructions, see the [Authentication Setup](/users/configuration/settings#environment-variables-for-api-access) documentation.
For detailed instructions, see the [Authentication Setup](../configuration/settings#environment-variables-for-api-access) documentation.

View File

@@ -31,7 +31,7 @@ This guide provides solutions to common issues and debugging tips, including top
1. In your home directory: `~/.qwen/settings.json`.
2. In your project's root directory: `./.qwen/settings.json`.
Refer to [Qwen Code Configuration](/users/configuration/settings) for more details.
Refer to [Qwen Code Configuration](../configuration/settings) for more details.
- **Q: Why don't I see cached token counts in my stats output?**
- A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Qwen API key or Google Cloud Vertex AI) but not for OAuth users (such as Google Personal/Enterprise accounts like Google Gmail or Google Workspace, respectively). This is because the Qwen Code Assist API does not support cached content creation. You can still view your total token usage using the `/stats` command.
@@ -59,7 +59,7 @@ This guide provides solutions to common issues and debugging tips, including top
- **Error: "Operation not permitted", "Permission denied", or similar.**
- **Cause:** When sandboxing is enabled, Qwen Code may attempt operations that are restricted by your sandbox configuration, such as writing outside the project directory or system temp directory.
- **Solution:** Refer to the [Configuration: Sandboxing](/users/features/sandbox) documentation for more information, including how to customize your sandbox configuration.
- **Solution:** Refer to the [Configuration: Sandboxing](../features/sandbox) documentation for more information, including how to customize your sandbox configuration.
- **Qwen Code is not running in interactive mode in "CI" environments**
- **Issue:** Qwen Code does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g. `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment.
@@ -84,12 +84,12 @@ This guide provides solutions to common issues and debugging tips, including top
The Qwen Code uses specific exit codes to indicate the reason for termination. This is especially useful for scripting and automation.
| Exit Code | Error Type | Description |
| --------- | -------------------------- | ------------------------------------------------------------ |
| 41 | `FatalAuthenticationError` | An error occurred during the authentication process. |
| 42 | `FatalInputError` | Invalid or missing input was provided to the CLI. (non-interactive mode only) |
| 44 | `FatalSandboxError` | An error occurred with the sandboxing environment (e.g. Docker, Podman, or Seatbelt). |
| 52 | `FatalConfigError` | A configuration file (`settings.json`) is invalid or contains errors. |
| Exit Code | Error Type | Description |
| --------- | -------------------------- | --------------------------------------------------------------------------------------------------- |
| 41 | `FatalAuthenticationError` | An error occurred during the authentication process. |
| 42 | `FatalInputError` | Invalid or missing input was provided to the CLI. (non-interactive mode only) |
| 44 | `FatalSandboxError` | An error occurred with the sandboxing environment (e.g. Docker, Podman, or Seatbelt). |
| 52 | `FatalConfigError` | A configuration file (`settings.json`) is invalid or contains errors. |
| 53 | `FatalTurnLimitedError` | The maximum number of conversational turns for the session was reached. (non-interactive mode only) |
## Debugging Tips

View File

@@ -5,8 +5,8 @@
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { writeFileSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { TestRig } from './test-helper.js';
// Windows skip (Option A: avoid infra scope)
@@ -121,21 +121,4 @@ d('BOM end-to-end integration', () => {
'BOM_OK UTF-32BE',
);
});
it('Can describe a PNG file', async () => {
const imagePath = resolve(
process.cwd(),
'docs/assets/gemini-screenshot.png',
);
const imageContent = readFileSync(imagePath);
const filename = 'gemini-screenshot.png';
writeFileSync(join(dir, filename), imageContent);
const prompt = `What is shown in the image ${filename}?`;
const output = await rig.run(prompt);
await rig.waitForToolCall('read_file');
const lower = output.toLowerCase();
// The response is non-deterministic, so we just check for some
// keywords that are very likely to be in the response.
expect(lower.includes('gemini')).toBeTruthy();
});
});

View File

@@ -1002,6 +1002,7 @@ export async function loadCliConfig(
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
eventEmitter: appEvents,
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
gitCoAuthor: settings.general?.gitCoAuthor,
output: {
format: outputSettingsFormat,
},

View File

@@ -581,7 +581,7 @@ function extensionConsentString(extensionConfig: ExtensionConfig): string {
}
if (extensionConfig.contextFileName) {
output.push(
`This extension will append info to your gemini.md context using ${extensionConfig.contextFileName}`,
`This extension will append info to your QWEN.md context using ${extensionConfig.contextFileName}`,
);
}
if (extensionConfig.excludeTools) {

View File

@@ -147,6 +147,16 @@ const SETTINGS_SCHEMA = {
description: 'Disable update notification prompts.',
showInDialog: false,
},
gitCoAuthor: {
type: 'boolean',
label: 'Git Co-Author',
category: 'General',
requiresRestart: false,
default: true,
description:
'Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code.',
showInDialog: false,
},
checkpointing: {
type: 'object',
label: 'Checkpointing',
@@ -284,7 +294,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: false,
default: false,
description:
'Show Gemini CLI status and thoughts in the terminal window title',
'Show Qwen Code status and thoughts in the terminal window title',
showInDialog: true,
},
hideTips: {
@@ -312,7 +322,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: false,
default: false,
description:
'Hide the context summary (GEMINI.md, MCP servers) above the input.',
'Hide the context summary (QWEN.md, MCP servers) above the input.',
showInDialog: true,
},
footer: {
@@ -518,7 +528,7 @@ const SETTINGS_SCHEMA = {
category: 'Model',
requiresRestart: false,
default: undefined as string | undefined,
description: 'The Gemini model to use for conversations.',
description: 'The model to use for conversations.',
showInDialog: false,
},
maxSessionTurns: {
@@ -649,6 +659,22 @@ const SETTINGS_SCHEMA = {
childKey: 'disableCacheControl',
showInDialog: true,
},
schemaCompliance: {
type: 'enum',
label: 'Tool Schema Compliance',
category: 'Generation Configuration',
requiresRestart: false,
default: 'auto',
description:
'The compliance mode for tool schemas sent to the model. Use "openapi_30" for strict OpenAPI 3.0 compatibility (e.g., for Gemini).',
parentKey: 'generationConfig',
childKey: 'schemaCompliance',
showInDialog: true,
options: [
{ value: 'auto', label: 'Auto (Default)' },
{ value: 'openapi_30', label: 'OpenAPI 3.0 Strict' },
],
},
},
},
},

View File

@@ -379,8 +379,8 @@ describe('gemini.tsx main function kitty protocol', () => {
beforeEach(() => {
// Set no relaunch in tests since process spawning causing issues in tests
originalEnvNoRelaunch = process.env['GEMINI_CLI_NO_RELAUNCH'];
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
originalEnvNoRelaunch = process.env['QWEN_CODE_NO_RELAUNCH'];
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(process.stdin as any).setRawMode) {
@@ -402,9 +402,9 @@ describe('gemini.tsx main function kitty protocol', () => {
afterEach(() => {
// Restore original env variables
if (originalEnvNoRelaunch !== undefined) {
process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch;
process.env['QWEN_CODE_NO_RELAUNCH'] = originalEnvNoRelaunch;
} else {
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
delete process.env['QWEN_CODE_NO_RELAUNCH'];
}
});

View File

@@ -58,7 +58,7 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { getCliVersion } from './utils/version.js';
import { computeWindowTitle } from './utils/windowTitle.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { showResumeSessionPicker } from './ui/components/ResumeSessionPicker.js';
import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js';
export function validateDnsResolutionOrder(
order: string | undefined,
@@ -92,7 +92,7 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
);
}
if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
if (process.env['QWEN_CODE_NO_RELAUNCH']) {
return [];
}

View File

@@ -310,6 +310,7 @@ export default {
'Tool Output Truncation Lines': 'Tool Output Truncation Lines',
'Folder Trust': 'Folder Trust',
'Vision Model Preview': 'Vision Model Preview',
'Tool Schema Compliance': 'Tool Schema Compliance',
// Settings enum options
'Auto (detect from system)': 'Auto (detect from system)',
Text: 'Text',
@@ -635,8 +636,8 @@ export default {
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.':
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
"Error adding '{{path}}': {{error}}": "Error adding '{{path}}': {{error}}",
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}':
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}',
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}':
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}',
'Error refreshing memory: {{error}}': 'Error refreshing memory: {{error}}',
'Successfully added directories:\n- {{directories}}':
'Successfully added directories:\n- {{directories}}',

View File

@@ -300,6 +300,7 @@ export default {
'Tool Output Truncation Lines': '工具输出截断行数',
'Folder Trust': '文件夹信任',
'Vision Model Preview': '视觉模型预览',
'Tool Schema Compliance': '工具 Schema 兼容性',
// Settings enum options
'Auto (detect from system)': '自动(从系统检测)',
Text: '文本',
@@ -601,8 +602,8 @@ export default {
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.':
'/directory add 命令在限制性沙箱配置文件中不受支持。请改为在启动会话时使用 --include-directories。',
"Error adding '{{path}}': {{error}}": "添加 '{{path}}' 时出错:{{error}}",
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}':
'如果存在,已成功从以下目录添加 GEMINI.md 文件:\n- {{directories}}',
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}':
'如果存在,已成功从以下目录添加 QWEN.md 文件:\n- {{directories}}',
'Error refreshing memory: {{error}}': '刷新内存时出错:{{error}}',
'Successfully added directories:\n- {{directories}}':
'成功添加目录:\n- {{directories}}',

View File

@@ -29,6 +29,7 @@ import { modelCommand } from '../ui/commands/modelCommand.js';
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { resumeCommand } from '../ui/commands/resumeCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
import { summaryCommand } from '../ui/commands/summaryCommand.js';
@@ -76,6 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
quitCommand,
restoreCommand(this.config),
resumeCommand,
statsCommand,
summaryCommand,
themeCommand,

View File

@@ -53,6 +53,7 @@ import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
import { useResumeCommand } from './hooks/useResumeCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useVimMode } from './contexts/VimModeContext.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
@@ -203,7 +204,7 @@ export const AppContainer = (props: AppContainerProps) => {
const { stdout } = useStdout();
// Additional hooks moved from App.tsx
const { stats: sessionStats } = useSessionStats();
const { stats: sessionStats, startNewSession } = useSessionStats();
const logger = useLogger(config.storage, sessionStats.sessionId);
const branchName = useGitBranchName(config.getTargetDir());
@@ -435,6 +436,18 @@ export const AppContainer = (props: AppContainerProps) => {
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
useModelCommand();
const {
isResumeDialogOpen,
openResumeDialog,
closeResumeDialog,
handleResume,
} = useResumeCommand({
config,
historyManager,
startNewSession,
remount: refreshStatic,
});
const {
showWorkspaceMigrationDialog,
workspaceExtensions,
@@ -488,6 +501,7 @@ export const AppContainer = (props: AppContainerProps) => {
addConfirmUpdateExtensionRequest,
openSubagentCreateDialog,
openAgentsManagerDialog,
openResumeDialog,
}),
[
openAuthDialog,
@@ -502,6 +516,7 @@ export const AppContainer = (props: AppContainerProps) => {
addConfirmUpdateExtensionRequest,
openSubagentCreateDialog,
openAgentsManagerDialog,
openResumeDialog,
],
);
@@ -1194,7 +1209,8 @@ export const AppContainer = (props: AppContainerProps) => {
!!proQuotaRequest ||
isSubagentCreateDialogOpen ||
isAgentsManagerDialogOpen ||
isApprovalModeDialogOpen;
isApprovalModeDialogOpen ||
isResumeDialogOpen;
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
@@ -1222,6 +1238,7 @@ export const AppContainer = (props: AppContainerProps) => {
isModelDialogOpen,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
isResumeDialogOpen,
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
@@ -1312,6 +1329,7 @@ export const AppContainer = (props: AppContainerProps) => {
isModelDialogOpen,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
isResumeDialogOpen,
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
@@ -1421,6 +1439,10 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
closeSubagentCreateDialog,
closeAgentsManagerDialog,
// Resume session dialog
openResumeDialog,
closeResumeDialog,
handleResume,
}),
[
handleThemeSelect,
@@ -1453,6 +1475,10 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
closeSubagentCreateDialog,
closeAgentsManagerDialog,
// Resume session dialog
openResumeDialog,
closeResumeDialog,
handleResume,
],
);

View File

@@ -130,7 +130,7 @@ export const directoryCommand: SlashCommand = {
{
type: MessageType.INFO,
text: t(
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}',
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}',
{
directories: added.join('\n- '),
},

View File

@@ -89,7 +89,7 @@ describe('restoreCommand', () => {
).toEqual({
type: 'message',
messageType: 'error',
content: 'Could not determine the .gemini directory path.',
content: 'Could not determine the .qwen directory path.',
});
});

View File

@@ -28,7 +28,7 @@ async function restoreAction(
return {
type: 'message',
messageType: 'error',
content: 'Could not determine the .gemini directory path.',
content: 'Could not determine the .qwen directory path.',
};
}

View File

@@ -0,0 +1,38 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { resumeCommand } from './resumeCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('resumeCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should return a dialog action to open the resume dialog', async () => {
// Ensure the command has an action to test.
if (!resumeCommand.action) {
throw new Error('The resume command must have an action.');
}
const result = await resumeCommand.action(mockContext, '');
// Assert that the action returns the correct object to trigger the resume dialog.
expect(result).toEqual({
type: 'dialog',
dialog: 'resume',
});
});
it('should have the correct name and description', () => {
expect(resumeCommand.name).toBe('resume');
expect(resumeCommand.description).toBe('Resume a previous session');
});
});

View File

@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const resumeCommand: SlashCommand = {
name: 'resume',
kind: CommandKind.BUILT_IN,
get description() {
return t('Resume a previous session');
},
action: async (): Promise<SlashCommandActionReturn> => ({
type: 'dialog',
dialog: 'resume',
}),
};

View File

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

View File

@@ -36,6 +36,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
import { SessionPicker } from './SessionPicker.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@@ -290,5 +291,16 @@ export const DialogManager = ({
);
}
if (uiState.isResumeDialogOpen) {
return (
<SessionPicker
sessionService={config.getSessionService()}
currentBranch={uiState.branchName}
onSelect={uiActions.handleResume}
onCancel={uiActions.closeResumeDialog}
/>
);
}
return null;
};

View File

@@ -115,7 +115,7 @@ export function PermissionsModifyTrustDialog({
{needsRestart && (
<Box marginLeft={1} marginTop={1}>
<Text color={theme.status.warning}>
To apply the trust changes, Gemini CLI must be restarted. Press
To apply the trust changes, Qwen Code must be restarted. Press
&apos;r&apos; to restart CLI now.
</Text>
</Box>

View File

@@ -1,436 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { render, Box, Text, useInput, useApp } from 'ink';
import {
SessionService,
type SessionListItem,
type ListSessionsResult,
getGitBranch,
} from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { formatRelativeTime } from '../utils/formatters.js';
const PAGE_SIZE = 20;
interface SessionPickerProps {
sessionService: SessionService;
currentBranch?: string;
onSelect: (sessionId: string) => void;
onCancel: () => void;
}
/**
* Truncates text to fit within a given width, adding ellipsis if needed.
*/
function truncateText(text: string, maxWidth: number): string {
if (text.length <= maxWidth) return text;
if (maxWidth <= 3) return text.slice(0, maxWidth);
return text.slice(0, maxWidth - 3) + '...';
}
function SessionPicker({
sessionService,
currentBranch,
onSelect,
onCancel,
}: SessionPickerProps): React.JSX.Element {
const { exit } = useApp();
const [selectedIndex, setSelectedIndex] = useState(0);
const [sessionState, setSessionState] = useState<{
sessions: SessionListItem[];
hasMore: boolean;
nextCursor?: number;
}>({
sessions: [],
hasMore: true,
nextCursor: undefined,
});
const isLoadingMoreRef = useRef(false);
const [filterByBranch, setFilterByBranch] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const [terminalSize, setTerminalSize] = useState({
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
});
// Update terminal size on resize
useEffect(() => {
const handleResize = () => {
setTerminalSize({
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
});
};
process.stdout.on('resize', handleResize);
return () => {
process.stdout.off('resize', handleResize);
};
}, []);
// Filter sessions by current branch if filter is enabled
const filteredSessions =
filterByBranch && currentBranch
? sessionState.sessions.filter(
(session) => session.gitBranch === currentBranch,
)
: sessionState.sessions;
const hasSentinel = sessionState.hasMore;
// Reset selection when filter changes
useEffect(() => {
setSelectedIndex(0);
}, [filterByBranch]);
const loadMoreSessions = useCallback(async () => {
if (!sessionState.hasMore || isLoadingMoreRef.current) return;
isLoadingMoreRef.current = true;
try {
const result: ListSessionsResult = await sessionService.listSessions({
size: PAGE_SIZE,
cursor: sessionState.nextCursor,
});
setSessionState((prev) => ({
sessions: [...prev.sessions, ...result.items],
hasMore: result.hasMore && result.nextCursor !== undefined,
nextCursor: result.nextCursor,
}));
} finally {
isLoadingMoreRef.current = false;
}
}, [sessionService, sessionState.hasMore, sessionState.nextCursor]);
// Calculate visible items
// Reserved space: header (1), footer (1), separators (2), borders (2)
const reservedLines = 6;
// Each item takes 2 lines (prompt + metadata) + 1 line margin between items
// On average, this is ~3 lines per item, but the last item has no margin
const itemHeight = 3;
const maxVisibleItems = Math.max(
1,
Math.floor((terminalSize.height - reservedLines) / itemHeight),
);
// Calculate scroll offset
const scrollOffset = (() => {
if (filteredSessions.length <= maxVisibleItems) return 0;
const halfVisible = Math.floor(maxVisibleItems / 2);
let offset = selectedIndex - halfVisible;
offset = Math.max(0, offset);
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
return offset;
})();
const visibleSessions = filteredSessions.slice(
scrollOffset,
scrollOffset + maxVisibleItems,
);
const showScrollUp = scrollOffset > 0;
const showScrollDown =
scrollOffset + maxVisibleItems < filteredSessions.length;
// Sentinel (invisible) sits after the last session item; consider it visible
// once the viewport reaches the final real item.
const sentinelVisible =
hasSentinel && scrollOffset + maxVisibleItems >= filteredSessions.length;
// Load more when sentinel enters view or when filtered list is empty.
useEffect(() => {
if (!sessionState.hasMore || isLoadingMoreRef.current) return;
const shouldLoadMore =
filteredSessions.length === 0 ||
sentinelVisible ||
isLoadingMoreRef.current;
if (shouldLoadMore) {
void loadMoreSessions();
}
}, [
filteredSessions.length,
loadMoreSessions,
sessionState.hasMore,
sentinelVisible,
]);
// Handle keyboard input
useInput((input, key) => {
// Ignore input if already exiting
if (isExiting) {
return;
}
// Escape or Ctrl+C to cancel
if (key.escape || (key.ctrl && input === 'c')) {
setIsExiting(true);
onCancel();
exit();
return;
}
if (key.return) {
const session = filteredSessions[selectedIndex];
if (session) {
setIsExiting(true);
onSelect(session.sessionId);
exit();
}
return;
}
if (key.upArrow || input === 'k') {
setSelectedIndex((prev) => Math.max(0, prev - 1));
return;
}
if (key.downArrow || input === 'j') {
if (filteredSessions.length === 0) {
return;
}
setSelectedIndex((prev) =>
Math.min(filteredSessions.length - 1, prev + 1),
);
return;
}
if (input === 'b' || input === 'B') {
if (currentBranch) {
setFilterByBranch((prev) => !prev);
}
return;
}
});
// Filtered sessions may have changed, ensure selectedIndex is valid
useEffect(() => {
if (
selectedIndex >= filteredSessions.length &&
filteredSessions.length > 0
) {
setSelectedIndex(filteredSessions.length - 1);
}
}, [filteredSessions.length, selectedIndex]);
// Calculate content width (terminal width minus border padding)
const contentWidth = terminalSize.width - 4;
const promptMaxWidth = contentWidth - 4; // Account for " " prefix
// Return empty while exiting to prevent visual glitches
if (isExiting) {
return <Box />;
}
return (
<Box
flexDirection="column"
width={terminalSize.width}
height={terminalSize.height - 1}
overflow="hidden"
>
{/* Main container with single border */}
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
width={terminalSize.width}
height={terminalSize.height - 1}
overflow="hidden"
>
{/* Header row */}
<Box paddingX={1}>
<Text bold color={theme.text.primary}>
Resume Session
</Text>
</Box>
{/* Separator line */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat(terminalSize.width - 2)}
</Text>
</Box>
{/* Session list with auto-scrolling */}
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
{filteredSessions.length === 0 ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}>
{filterByBranch
? `No sessions found for branch "${currentBranch}"`
: 'No sessions found'}
</Text>
</Box>
) : (
visibleSessions.map((session, visibleIndex) => {
const actualIndex = scrollOffset + visibleIndex;
const isSelected = actualIndex === selectedIndex;
const isFirst = visibleIndex === 0;
const isLast = visibleIndex === visibleSessions.length - 1;
const timeAgo = formatRelativeTime(session.mtime);
const messageText =
session.messageCount === 1
? '1 message'
: `${session.messageCount} messages`;
// Show scroll indicator on first/last visible items
const showUpIndicator = isFirst && showScrollUp;
const showDownIndicator = isLast && showScrollDown;
// Determine the prefix: selector takes priority over scroll indicator
const prefix = isSelected
? ' '
: showUpIndicator
? '↑ '
: showDownIndicator
? '↓ '
: ' ';
return (
<Box
key={session.sessionId}
flexDirection="column"
marginBottom={isLast ? 0 : 1}
>
{/* First line: prefix (selector or scroll indicator) + prompt text */}
<Box>
<Text
color={
isSelected
? theme.text.accent
: showUpIndicator || showDownIndicator
? theme.text.secondary
: undefined
}
>
{prefix}
</Text>
<Text
bold={isSelected}
color={
isSelected ? theme.text.accent : theme.text.primary
}
>
{truncateText(
session.prompt || '(empty prompt)',
promptMaxWidth,
)}
</Text>
</Box>
{/* Second line: metadata (aligned with prompt text) */}
<Box>
<Text>{' '}</Text>
<Text color={theme.text.secondary}>
{timeAgo} · {messageText}
{session.gitBranch && ` · ${session.gitBranch}`}
</Text>
</Box>
</Box>
);
})
)}
</Box>
{/* Separator line */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat(terminalSize.width - 2)}
</Text>
</Box>
{/* Footer with keyboard shortcuts */}
<Box paddingX={1}>
<Text color={theme.text.secondary}>
{currentBranch && (
<>
<Text
bold={filterByBranch}
color={filterByBranch ? theme.text.accent : undefined}
>
B
</Text>
{' to toggle branch · '}
</>
)}
{'↑↓ to navigate · Esc to cancel'}
</Text>
</Box>
</Box>
</Box>
);
}
/**
* Clears the terminal screen.
*/
function clearScreen(): void {
// Move cursor to home position and clear screen
process.stdout.write('\x1b[2J\x1b[H');
}
/**
* Shows an interactive session picker and returns the selected session ID.
* Returns undefined if the user cancels or no sessions are available.
*/
export async function showResumeSessionPicker(
cwd: string = process.cwd(),
): Promise<string | undefined> {
const sessionService = new SessionService(cwd);
const hasSession = await sessionService.loadLastSession();
if (!hasSession) {
console.log('No sessions found. Start a new session with `qwen`.');
return undefined;
}
const currentBranch = getGitBranch(cwd);
// Clear the screen before showing the picker for a clean fullscreen experience
clearScreen();
// Enable raw mode for keyboard input if not already enabled
const wasRaw = process.stdin.isRaw;
if (process.stdin.isTTY && !wasRaw) {
process.stdin.setRawMode(true);
}
return new Promise<string | undefined>((resolve) => {
let selectedId: string | undefined;
const { unmount, waitUntilExit } = render(
<SessionPicker
sessionService={sessionService}
currentBranch={currentBranch}
onSelect={(id) => {
selectedId = id;
}}
onCancel={() => {
selectedId = undefined;
}}
/>,
{
exitOnCtrlC: false,
},
);
waitUntilExit().then(() => {
unmount();
// Clear the screen after the picker closes for a clean fullscreen experience
clearScreen();
// Restore raw mode state only if we changed it and user cancelled
// (if user selected a session, main app will handle raw mode)
if (process.stdin.isTTY && !wasRaw && !selectedId) {
process.stdin.setRawMode(false);
}
resolve(selectedId);
});
});
}

View File

@@ -0,0 +1,251 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type {
SessionListItem as SessionData,
SessionService,
} from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { useSessionPicker } from '../hooks/useSessionPicker.js';
import { formatRelativeTime } from '../utils/formatters.js';
import {
formatMessageCount,
truncateText,
} from '../utils/sessionPickerUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { t } from '../../i18n/index.js';
export interface SessionPickerProps {
sessionService: SessionService | null;
onSelect: (sessionId: string) => void;
onCancel: () => void;
currentBranch?: string;
/**
* Scroll mode. When true, keep selection centered (fullscreen-style).
* Defaults to true so dialog + standalone behave identically.
*/
centerSelection?: boolean;
}
const PREFIX_CHARS = {
selected: ' ',
scrollUp: '↑ ',
scrollDown: '↓ ',
normal: ' ',
};
interface SessionListItemViewProps {
session: SessionData;
isSelected: boolean;
isFirst: boolean;
isLast: boolean;
showScrollUp: boolean;
showScrollDown: boolean;
maxPromptWidth: number;
prefixChars?: {
selected: string;
scrollUp: string;
scrollDown: string;
normal: string;
};
boldSelectedPrefix?: boolean;
}
function SessionListItemView({
session,
isSelected,
isFirst,
isLast,
showScrollUp,
showScrollDown,
maxPromptWidth,
prefixChars = PREFIX_CHARS,
boldSelectedPrefix = true,
}: SessionListItemViewProps): React.JSX.Element {
const timeAgo = formatRelativeTime(session.mtime);
const messageText = formatMessageCount(session.messageCount);
const showUpIndicator = isFirst && showScrollUp;
const showDownIndicator = isLast && showScrollDown;
const prefix = isSelected
? prefixChars.selected
: showUpIndicator
? prefixChars.scrollUp
: showDownIndicator
? prefixChars.scrollDown
: prefixChars.normal;
const promptText = session.prompt || '(empty prompt)';
const truncatedPrompt = truncateText(promptText, maxPromptWidth);
return (
<Box flexDirection="column" marginBottom={isLast ? 0 : 1}>
<Box>
<Text
color={
isSelected
? theme.text.accent
: showUpIndicator || showDownIndicator
? theme.text.secondary
: undefined
}
bold={isSelected && boldSelectedPrefix}
>
{prefix}
</Text>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
bold={isSelected}
>
{truncatedPrompt}
</Text>
</Box>
<Box paddingLeft={2}>
<Text color={theme.text.secondary}>
{timeAgo} · {messageText}
{session.gitBranch && ` · ${session.gitBranch}`}
</Text>
</Box>
</Box>
);
}
export function SessionPicker(props: SessionPickerProps) {
const {
sessionService,
onSelect,
onCancel,
currentBranch,
centerSelection = true,
} = props;
const { columns: width, rows: height } = useTerminalSize();
// Calculate box width (width + 6 for border padding)
const boxWidth = width + 6;
// Calculate visible items (same heuristic as before)
// Reserved space: header (1), footer (1), separators (2), borders (2)
const reservedLines = 6;
// Each item takes 2 lines (prompt + metadata) + 1 line margin between items
const itemHeight = 3;
const maxVisibleItems = Math.max(
1,
Math.floor((height - reservedLines) / itemHeight),
);
const picker = useSessionPicker({
sessionService,
currentBranch,
onSelect,
onCancel,
maxVisibleItems,
centerSelection,
isActive: true,
});
return (
<Box
flexDirection="column"
width={boxWidth}
height={height - 1}
overflow="hidden"
>
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
width={boxWidth}
height={height - 1}
overflow="hidden"
>
{/* Header row */}
<Box paddingX={1}>
<Text bold color={theme.text.primary}>
{t('Resume Session')}
</Text>
{picker.filterByBranch && currentBranch && (
<Text color={theme.text.secondary}>
{' '}
{t('(branch: {{branch}})', { branch: currentBranch })}
</Text>
)}
</Box>
{/* Separator */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
</Box>
{/* Session list */}
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
{!sessionService || picker.isLoading ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}>
{t('Loading sessions...')}
</Text>
</Box>
) : picker.filteredSessions.length === 0 ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}>
{picker.filterByBranch
? t('No sessions found for branch "{{branch}}"', {
branch: currentBranch ?? '',
})
: t('No sessions found')}
</Text>
</Box>
) : (
picker.visibleSessions.map((session, visibleIndex) => {
const actualIndex = picker.scrollOffset + visibleIndex;
return (
<SessionListItemView
key={session.sessionId}
session={session}
isSelected={actualIndex === picker.selectedIndex}
isFirst={visibleIndex === 0}
isLast={visibleIndex === picker.visibleSessions.length - 1}
showScrollUp={picker.showScrollUp}
showScrollDown={picker.showScrollDown}
maxPromptWidth={width}
prefixChars={PREFIX_CHARS}
boldSelectedPrefix={false}
/>
);
})
)}
</Box>
{/* Separator */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
</Box>
{/* Footer */}
<Box paddingX={1}>
<Box flexDirection="row">
{currentBranch && (
<Text color={theme.text.secondary}>
<Text
bold={picker.filterByBranch}
color={picker.filterByBranch ? theme.text.accent : undefined}
>
B
</Text>
{t(' to toggle branch')} ·
</Text>
)}
<Text color={theme.text.secondary}>
{t('↑↓ to navigate · Esc to cancel')}
</Text>
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,624 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { SessionPicker } from './SessionPicker.js';
import type {
SessionListItem,
ListSessionsResult,
} from '@qwen-code/qwen-code-core';
vi.mock('@qwen-code/qwen-code-core', async () => {
const actual = await vi.importActual('@qwen-code/qwen-code-core');
return {
...actual,
getGitBranch: vi.fn().mockReturnValue('main'),
};
});
// Mock terminal size
const mockTerminalSize = { columns: 80, rows: 24 };
beforeEach(() => {
Object.defineProperty(process.stdout, 'columns', {
value: mockTerminalSize.columns,
configurable: true,
});
Object.defineProperty(process.stdout, 'rows', {
value: mockTerminalSize.rows,
configurable: true,
});
});
// Helper to create mock sessions
function createMockSession(
overrides: Partial<SessionListItem> = {},
): SessionListItem {
return {
sessionId: 'test-session-id',
cwd: '/test/path',
startTime: '2025-01-01T00:00:00.000Z',
mtime: Date.now(),
prompt: 'Test prompt',
gitBranch: 'main',
filePath: '/test/path/sessions/test-session-id.jsonl',
messageCount: 5,
...overrides,
};
}
// Helper to create mock session service
function createMockSessionService(
sessions: SessionListItem[] = [],
hasMore = false,
) {
return {
listSessions: vi.fn().mockResolvedValue({
items: sessions,
hasMore,
nextCursor: hasMore ? Date.now() : undefined,
} as ListSessionsResult),
loadSession: vi.fn(),
loadLastSession: vi
.fn()
.mockResolvedValue(sessions.length > 0 ? {} : undefined),
};
}
describe('SessionPicker', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
afterEach(() => {
vi.clearAllMocks();
});
describe('Empty Sessions', () => {
it('should show sessions with 0 messages', async () => {
const sessions = [
createMockSession({
sessionId: 'empty-1',
messageCount: 0,
prompt: '',
}),
createMockSession({
sessionId: 'with-messages',
messageCount: 5,
prompt: 'Hello',
}),
createMockSession({
sessionId: 'empty-2',
messageCount: 0,
prompt: '(empty prompt)',
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('Hello');
// Should show empty sessions too (rendered as "(empty prompt)" + "0 messages")
expect(output).toContain('0 messages');
});
it('should show sessions even when all sessions are empty', async () => {
const sessions = [
createMockSession({ sessionId: 'empty-1', messageCount: 0 }),
createMockSession({ sessionId: 'empty-2', messageCount: 0 }),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('0 messages');
});
it('should show sessions with 1 or more messages', async () => {
const sessions = [
createMockSession({
sessionId: 'one-msg',
messageCount: 1,
prompt: 'Single message',
}),
createMockSession({
sessionId: 'many-msg',
messageCount: 10,
prompt: 'Many messages',
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('Single message');
expect(output).toContain('Many messages');
expect(output).toContain('1 message');
expect(output).toContain('10 messages');
});
});
describe('Branch Filtering', () => {
it('should filter by branch when B is pressed', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
gitBranch: 'main',
prompt: 'Main branch',
messageCount: 1,
}),
createMockSession({
sessionId: 's2',
gitBranch: 'feature',
prompt: 'Feature branch',
messageCount: 1,
}),
createMockSession({
sessionId: 's3',
gitBranch: 'main',
prompt: 'Also main',
messageCount: 1,
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame, stdin } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
currentBranch="main"
/>
</KeypressProvider>,
);
await wait(100);
// All sessions should be visible initially
let output = lastFrame();
expect(output).toContain('Main branch');
expect(output).toContain('Feature branch');
// Press B to filter by branch
stdin.write('B');
await wait(50);
output = lastFrame();
// Only main branch sessions should be visible
expect(output).toContain('Main branch');
expect(output).toContain('Also main');
expect(output).not.toContain('Feature branch');
});
it('should combine empty session filter with branch filter', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
gitBranch: 'main',
messageCount: 0,
prompt: 'Empty main',
}),
createMockSession({
sessionId: 's2',
gitBranch: 'main',
messageCount: 5,
prompt: 'Valid main',
}),
createMockSession({
sessionId: 's3',
gitBranch: 'feature',
messageCount: 5,
prompt: 'Valid feature',
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame, stdin } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
currentBranch="main"
/>
</KeypressProvider>,
);
await wait(100);
// Press B to filter by branch
stdin.write('B');
await wait(50);
const output = lastFrame();
// Should only show sessions from main branch (including 0-message sessions)
expect(output).toContain('Valid main');
expect(output).toContain('Empty main');
expect(output).not.toContain('Valid feature');
});
});
describe('Keyboard Navigation', () => {
it('should navigate with arrow keys', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
prompt: 'First session',
messageCount: 1,
}),
createMockSession({
sessionId: 's2',
prompt: 'Second session',
messageCount: 1,
}),
createMockSession({
sessionId: 's3',
prompt: 'Third session',
messageCount: 1,
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame, stdin } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
// First session should be selected initially (indicated by >)
let output = lastFrame();
expect(output).toContain('First session');
// Navigate down
stdin.write('\u001B[B'); // Down arrow
await wait(50);
output = lastFrame();
// Selection indicator should move
expect(output).toBeDefined();
});
it('should navigate with vim keys (j/k)', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
prompt: 'First',
messageCount: 1,
}),
createMockSession({
sessionId: 's2',
prompt: 'Second',
messageCount: 1,
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { stdin, unmount } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
// Navigate with j (down)
stdin.write('j');
await wait(50);
// Navigate with k (up)
stdin.write('k');
await wait(50);
unmount();
});
it('should select session on Enter', async () => {
const sessions = [
createMockSession({
sessionId: 'selected-session',
prompt: 'Select me',
messageCount: 1,
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { stdin } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
// Press Enter to select
stdin.write('\r');
await wait(50);
expect(onSelect).toHaveBeenCalledWith('selected-session');
});
it('should cancel on Escape', async () => {
const sessions = [
createMockSession({ sessionId: 's1', messageCount: 1 }),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { stdin } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
// Press Escape to cancel
stdin.write('\u001B');
await wait(50);
expect(onCancel).toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
});
});
describe('Display', () => {
it('should show session metadata', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
prompt: 'Test prompt text',
messageCount: 5,
gitBranch: 'feature-branch',
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('Test prompt text');
expect(output).toContain('5 messages');
expect(output).toContain('feature-branch');
});
it('should show header and footer', async () => {
const sessions = [createMockSession({ messageCount: 1 })];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('Resume Session');
expect(output).toContain('↑↓ to navigate');
expect(output).toContain('Esc to cancel');
});
it('should show branch toggle hint when currentBranch is provided', async () => {
const sessions = [createMockSession({ messageCount: 1 })];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
currentBranch="main"
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('B');
expect(output).toContain('toggle branch');
});
it('should truncate long prompts', async () => {
const longPrompt = 'A'.repeat(300);
const sessions = [
createMockSession({ prompt: longPrompt, messageCount: 1 }),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
// Should contain ellipsis for truncated text
expect(output).toContain('...');
// Should NOT contain the full untruncated prompt (300 A's in a row)
expect(output).not.toContain(longPrompt);
});
it('should show "(empty prompt)" for sessions without prompt text', async () => {
const sessions = [createMockSession({ prompt: '', messageCount: 1 })];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('(empty prompt)');
});
});
describe('Pagination', () => {
it('should load more sessions when scrolling to bottom', async () => {
const firstPage = Array.from({ length: 5 }, (_, i) =>
createMockSession({
sessionId: `session-${i}`,
prompt: `Session ${i}`,
messageCount: 1,
mtime: Date.now() - i * 1000,
}),
);
const secondPage = Array.from({ length: 3 }, (_, i) =>
createMockSession({
sessionId: `session-${i + 5}`,
prompt: `Session ${i + 5}`,
messageCount: 1,
mtime: Date.now() - (i + 5) * 1000,
}),
);
const mockService = {
listSessions: vi
.fn()
.mockResolvedValueOnce({
items: firstPage,
hasMore: true,
nextCursor: Date.now() - 5000,
})
.mockResolvedValueOnce({
items: secondPage,
hasMore: false,
nextCursor: undefined,
}),
loadSession: vi.fn(),
loadLastSession: vi.fn().mockResolvedValue({}),
};
const onSelect = vi.fn();
const onCancel = vi.fn();
const { unmount } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(200);
// First page should be loaded
expect(mockService.listSessions).toHaveBeenCalled();
unmount();
});
});
});

View File

@@ -0,0 +1,122 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { useState } from 'react';
import { render, Box, useApp } from 'ink';
import { getGitBranch, SessionService } from '@qwen-code/qwen-code-core';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { SessionPicker } from './SessionPicker.js';
interface StandalonePickerScreenProps {
sessionService: SessionService;
onSelect: (sessionId: string) => void;
onCancel: () => void;
currentBranch?: string;
}
function StandalonePickerScreen({
sessionService,
onSelect,
onCancel,
currentBranch,
}: StandalonePickerScreenProps): React.JSX.Element {
const { exit } = useApp();
const [isExiting, setIsExiting] = useState(false);
const handleExit = () => {
setIsExiting(true);
exit();
};
// Return empty while exiting to prevent visual glitches
if (isExiting) {
return <Box />;
}
return (
<SessionPicker
sessionService={sessionService}
onSelect={(id) => {
onSelect(id);
handleExit();
}}
onCancel={() => {
onCancel();
handleExit();
}}
currentBranch={currentBranch}
centerSelection={true}
/>
);
}
/**
* Clears the terminal screen.
*/
function clearScreen(): void {
// Move cursor to home position and clear screen
process.stdout.write('\x1b[2J\x1b[H');
}
/**
* Shows an interactive session picker and returns the selected session ID.
* Returns undefined if the user cancels or no sessions are available.
*/
export async function showResumeSessionPicker(
cwd: string = process.cwd(),
): Promise<string | undefined> {
const sessionService = new SessionService(cwd);
const hasSession = await sessionService.loadLastSession();
if (!hasSession) {
console.log('No sessions found. Start a new session with `qwen`.');
return undefined;
}
// Clear the screen before showing the picker for a clean fullscreen experience
clearScreen();
// Enable raw mode for keyboard input if not already enabled
const wasRaw = process.stdin.isRaw;
if (process.stdin.isTTY && !wasRaw) {
process.stdin.setRawMode(true);
}
return new Promise<string | undefined>((resolve) => {
let selectedId: string | undefined;
const { unmount, waitUntilExit } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<StandalonePickerScreen
sessionService={sessionService}
onSelect={(id) => {
selectedId = id;
}}
onCancel={() => {
selectedId = undefined;
}}
currentBranch={getGitBranch(cwd)}
/>
</KeypressProvider>,
{
exitOnCtrlC: false,
},
);
waitUntilExit().then(() => {
unmount();
// Clear the screen after the picker closes for a clean fullscreen experience
clearScreen();
// Restore raw mode state only if we changed it and user cancelled
// (if user selected a session, main app will handle raw mode)
if (process.stdin.isTTY && !wasRaw && !selectedId) {
process.stdin.setRawMode(false);
}
resolve(selectedId);
});
});
}

View File

@@ -64,6 +64,10 @@ export interface UIActions {
// Subagent dialogs
closeSubagentCreateDialog: () => void;
closeAgentsManagerDialog: () => void;
// Resume session dialog
openResumeDialog: () => void;
closeResumeDialog: () => void;
handleResume: (sessionId: string) => void;
}
export const UIActionsContext = createContext<UIActions | null>(null);

View File

@@ -60,6 +60,7 @@ export interface UIState {
isModelDialogOpen: boolean;
isPermissionsDialogOpen: boolean;
isApprovalModeDialogOpen: boolean;
isResumeDialogOpen: boolean;
slashCommands: readonly SlashCommand[];
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
commandContext: CommandContext;

View File

@@ -56,6 +56,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
'clear',
'reset',
'new',
'resume',
]);
interface SlashCommandProcessorActions {
@@ -66,6 +67,7 @@ interface SlashCommandProcessorActions {
openModelDialog: () => void;
openPermissionsDialog: () => void;
openApprovalModeDialog: () => void;
openResumeDialog: () => void;
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
@@ -417,6 +419,9 @@ export const useSlashCommandProcessor = (
case 'approval-mode':
actions.openApprovalModeDialog();
return { type: 'handled' };
case 'resume':
actions.openResumeDialog();
return { type: 'handled' };
case 'help':
return { type: 'handled' };
default: {

View File

@@ -0,0 +1,190 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { act, renderHook } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useResumeCommand } from './useResumeCommand.js';
const resumeMocks = vi.hoisted(() => {
let resolveLoadSession:
| ((value: { conversation: unknown } | undefined) => void)
| undefined;
let pendingLoadSession:
| Promise<{ conversation: unknown } | undefined>
| undefined;
return {
createPendingLoadSession() {
pendingLoadSession = new Promise((resolve) => {
resolveLoadSession = resolve;
});
return pendingLoadSession;
},
resolvePendingLoadSession(value: { conversation: unknown } | undefined) {
resolveLoadSession?.(value);
},
getPendingLoadSession() {
return pendingLoadSession;
},
reset() {
resolveLoadSession = undefined;
pendingLoadSession = undefined;
},
};
});
vi.mock('../utils/resumeHistoryUtils.js', () => ({
buildResumedHistoryItems: vi.fn(() => [{ id: 1, type: 'user', text: 'hi' }]),
}));
vi.mock('@qwen-code/qwen-code-core', () => {
class SessionService {
constructor(_cwd: string) {}
async loadSession(_sessionId: string) {
return (
resumeMocks.getPendingLoadSession() ??
Promise.resolve({
conversation: [{ role: 'user', parts: [{ text: 'hello' }] }],
})
);
}
}
return {
SessionService,
};
});
describe('useResumeCommand', () => {
it('should initialize with dialog closed', () => {
const { result } = renderHook(() => useResumeCommand());
expect(result.current.isResumeDialogOpen).toBe(false);
});
it('should open the dialog when openResumeDialog is called', () => {
const { result } = renderHook(() => useResumeCommand());
act(() => {
result.current.openResumeDialog();
});
expect(result.current.isResumeDialogOpen).toBe(true);
});
it('should close the dialog when closeResumeDialog is called', () => {
const { result } = renderHook(() => useResumeCommand());
// Open the dialog first
act(() => {
result.current.openResumeDialog();
});
expect(result.current.isResumeDialogOpen).toBe(true);
// Close the dialog
act(() => {
result.current.closeResumeDialog();
});
expect(result.current.isResumeDialogOpen).toBe(false);
});
it('should maintain stable function references across renders', () => {
const { result, rerender } = renderHook(() => useResumeCommand());
const initialOpenFn = result.current.openResumeDialog;
const initialCloseFn = result.current.closeResumeDialog;
const initialHandleResume = result.current.handleResume;
rerender();
expect(result.current.openResumeDialog).toBe(initialOpenFn);
expect(result.current.closeResumeDialog).toBe(initialCloseFn);
expect(result.current.handleResume).toBe(initialHandleResume);
});
it('handleResume no-ops when config is null', async () => {
const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() };
const startNewSession = vi.fn();
const { result } = renderHook(() =>
useResumeCommand({
config: null,
historyManager,
startNewSession,
}),
);
await act(async () => {
await result.current.handleResume('session-1');
});
expect(startNewSession).not.toHaveBeenCalled();
expect(historyManager.clearItems).not.toHaveBeenCalled();
expect(historyManager.loadHistory).not.toHaveBeenCalled();
});
it('handleResume closes the dialog immediately and restores session state', async () => {
resumeMocks.reset();
resumeMocks.createPendingLoadSession();
const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() };
const startNewSession = vi.fn();
const geminiClient = {
initialize: vi.fn(),
};
const config = {
getTargetDir: () => '/tmp',
getGeminiClient: () => geminiClient,
startNewSession: vi.fn(),
} as unknown as import('@qwen-code/qwen-code-core').Config;
const { result } = renderHook(() =>
useResumeCommand({
config,
historyManager,
startNewSession,
}),
);
// Open first so we can verify the dialog closes immediately.
act(() => {
result.current.openResumeDialog();
});
expect(result.current.isResumeDialogOpen).toBe(true);
let resumePromise: Promise<void> | undefined;
act(() => {
// Start resume but do not await it yet — we want to assert the dialog
// closes immediately before the async session load completes.
resumePromise = result.current.handleResume('session-2') as unknown as
| Promise<void>
| undefined;
});
expect(result.current.isResumeDialogOpen).toBe(false);
// Now finish the async load and let the handler complete.
resumeMocks.resolvePendingLoadSession({
conversation: [{ role: 'user', parts: [{ text: 'hello' }] }],
});
await act(async () => {
await resumePromise;
});
expect(config.startNewSession).toHaveBeenCalledWith(
'session-2',
expect.objectContaining({
conversation: expect.anything(),
}),
);
expect(startNewSession).toHaveBeenCalledWith('session-2');
expect(geminiClient.initialize).toHaveBeenCalledTimes(1);
expect(historyManager.clearItems).toHaveBeenCalledTimes(1);
expect(historyManager.loadHistory).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
import { SessionService, type Config } from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
export interface UseResumeCommandOptions {
config: Config | null;
historyManager: Pick<UseHistoryManagerReturn, 'clearItems' | 'loadHistory'>;
startNewSession: (sessionId: string) => void;
remount?: () => void;
}
export interface UseResumeCommandResult {
isResumeDialogOpen: boolean;
openResumeDialog: () => void;
closeResumeDialog: () => void;
handleResume: (sessionId: string) => void;
}
export function useResumeCommand(
options?: UseResumeCommandOptions,
): UseResumeCommandResult {
const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false);
const openResumeDialog = useCallback(() => {
setIsResumeDialogOpen(true);
}, []);
const closeResumeDialog = useCallback(() => {
setIsResumeDialogOpen(false);
}, []);
const { config, historyManager, startNewSession, remount } = options ?? {};
const handleResume = useCallback(
async (sessionId: string) => {
if (!config || !historyManager || !startNewSession) {
return;
}
// Close dialog immediately to prevent input capture during async operations.
closeResumeDialog();
const cwd = config.getTargetDir();
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
return;
}
// Start new session in UI context.
startNewSession(sessionId);
// Reset UI history.
const uiHistoryItems = buildResumedHistoryItems(sessionData, config);
historyManager.clearItems();
historyManager.loadHistory(uiHistoryItems);
// Update session history core.
config.startNewSession(sessionId, sessionData);
await config.getGeminiClient()?.initialize?.();
// Refresh terminal UI.
remount?.();
},
[closeResumeDialog, config, historyManager, startNewSession, remount],
);
return {
isResumeDialogOpen,
openResumeDialog,
closeResumeDialog,
handleResume,
};
}

View File

@@ -0,0 +1,279 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Unified session picker hook for both dialog and standalone modes.
*
* IMPORTANT:
* - Uses KeypressContext (`useKeypress`) so it behaves correctly inside the main app.
* - Standalone mode should wrap the picker in `<KeypressProvider>` when rendered
* outside the main app.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
ListSessionsResult,
SessionListItem,
SessionService,
} from '@qwen-code/qwen-code-core';
import {
filterSessions,
SESSION_PAGE_SIZE,
type SessionState,
} from '../utils/sessionPickerUtils.js';
import { useKeypress } from './useKeypress.js';
export interface UseSessionPickerOptions {
sessionService: SessionService | null;
currentBranch?: string;
onSelect: (sessionId: string) => void;
onCancel: () => void;
maxVisibleItems: number;
/**
* If true, computes centered scroll offset (keeps selection near middle).
* If false, uses follow mode (scrolls when selection reaches edge).
*/
centerSelection?: boolean;
/**
* Enable/disable input handling.
*/
isActive?: boolean;
}
export interface UseSessionPickerResult {
selectedIndex: number;
sessionState: SessionState;
filteredSessions: SessionListItem[];
filterByBranch: boolean;
isLoading: boolean;
scrollOffset: number;
visibleSessions: SessionListItem[];
showScrollUp: boolean;
showScrollDown: boolean;
loadMoreSessions: () => Promise<void>;
}
export function useSessionPicker({
sessionService,
currentBranch,
onSelect,
onCancel,
maxVisibleItems,
centerSelection = false,
isActive = true,
}: UseSessionPickerOptions): UseSessionPickerResult {
const [selectedIndex, setSelectedIndex] = useState(0);
const [sessionState, setSessionState] = useState<SessionState>({
sessions: [],
hasMore: true,
nextCursor: undefined,
});
const [filterByBranch, setFilterByBranch] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// For follow mode (non-centered)
const [followScrollOffset, setFollowScrollOffset] = useState(0);
const isLoadingMoreRef = useRef(false);
const filteredSessions = useMemo(
() => filterSessions(sessionState.sessions, filterByBranch, currentBranch),
[sessionState.sessions, filterByBranch, currentBranch],
);
const scrollOffset = useMemo(() => {
if (centerSelection) {
if (filteredSessions.length <= maxVisibleItems) {
return 0;
}
const halfVisible = Math.floor(maxVisibleItems / 2);
let offset = selectedIndex - halfVisible;
offset = Math.max(0, offset);
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
return offset;
}
return followScrollOffset;
}, [
centerSelection,
filteredSessions.length,
followScrollOffset,
maxVisibleItems,
selectedIndex,
]);
const visibleSessions = useMemo(
() => filteredSessions.slice(scrollOffset, scrollOffset + maxVisibleItems),
[filteredSessions, maxVisibleItems, scrollOffset],
);
const showScrollUp = scrollOffset > 0;
const showScrollDown =
scrollOffset + maxVisibleItems < filteredSessions.length;
// Initial load
useEffect(() => {
if (!sessionService) {
return;
}
const loadInitialSessions = async () => {
try {
const result: ListSessionsResult = await sessionService.listSessions({
size: SESSION_PAGE_SIZE,
});
setSessionState({
sessions: result.items,
hasMore: result.hasMore,
nextCursor: result.nextCursor,
});
} finally {
setIsLoading(false);
}
};
void loadInitialSessions();
}, [sessionService]);
const loadMoreSessions = useCallback(async () => {
if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) {
return;
}
isLoadingMoreRef.current = true;
try {
const result: ListSessionsResult = await sessionService.listSessions({
size: SESSION_PAGE_SIZE,
cursor: sessionState.nextCursor,
});
setSessionState((prev) => ({
sessions: [...prev.sessions, ...result.items],
hasMore: result.hasMore && result.nextCursor !== undefined,
nextCursor: result.nextCursor,
}));
} finally {
isLoadingMoreRef.current = false;
}
}, [sessionService, sessionState.hasMore, sessionState.nextCursor]);
// Reset selection when filter changes
useEffect(() => {
setSelectedIndex(0);
setFollowScrollOffset(0);
}, [filterByBranch]);
// Ensure selectedIndex is valid when filtered sessions change
useEffect(() => {
if (
selectedIndex >= filteredSessions.length &&
filteredSessions.length > 0
) {
setSelectedIndex(filteredSessions.length - 1);
}
}, [filteredSessions.length, selectedIndex]);
// Auto-load more when centered mode hits the sentinel or list is empty.
useEffect(() => {
if (
isLoading ||
!sessionState.hasMore ||
isLoadingMoreRef.current ||
!centerSelection
) {
return;
}
const sentinelVisible =
scrollOffset + maxVisibleItems >= filteredSessions.length;
const shouldLoadMore = filteredSessions.length === 0 || sentinelVisible;
if (shouldLoadMore) {
void loadMoreSessions();
}
}, [
centerSelection,
filteredSessions.length,
isLoading,
loadMoreSessions,
maxVisibleItems,
scrollOffset,
sessionState.hasMore,
]);
// Key handling (KeypressContext)
useKeypress(
(key) => {
const { name, sequence, ctrl } = key;
if (name === 'escape' || (ctrl && name === 'c')) {
onCancel();
return;
}
if (name === 'return') {
const session = filteredSessions[selectedIndex];
if (session) {
onSelect(session.sessionId);
}
return;
}
if (name === 'up' || name === 'k') {
setSelectedIndex((prev) => {
const newIndex = Math.max(0, prev - 1);
if (!centerSelection && newIndex < followScrollOffset) {
setFollowScrollOffset(newIndex);
}
return newIndex;
});
return;
}
if (name === 'down' || name === 'j') {
if (filteredSessions.length === 0) {
return;
}
setSelectedIndex((prev) => {
const newIndex = Math.min(filteredSessions.length - 1, prev + 1);
if (
!centerSelection &&
newIndex >= followScrollOffset + maxVisibleItems
) {
setFollowScrollOffset(newIndex - maxVisibleItems + 1);
}
// Follow mode: load more when near the end.
if (!centerSelection && newIndex >= filteredSessions.length - 3) {
void loadMoreSessions();
}
return newIndex;
});
return;
}
if (sequence === 'b' || sequence === 'B') {
if (currentBranch) {
setFilterByBranch((prev) => !prev);
}
}
},
{ isActive },
);
return {
selectedIndex,
sessionState,
filteredSessions,
filterByBranch,
isLoading,
scrollOffset,
visibleSessions,
showScrollUp,
showScrollDown,
loadMoreSessions,
};
}

View File

@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { truncateText } from './sessionPickerUtils.js';
describe('sessionPickerUtils', () => {
describe('truncateText', () => {
it('returns the original text when it fits and has no newline', () => {
expect(truncateText('hello', 10)).toBe('hello');
});
it('truncates long text with ellipsis', () => {
expect(truncateText('hello world', 5)).toBe('he...');
});
it('truncates without ellipsis when maxWidth <= 3', () => {
expect(truncateText('hello', 3)).toBe('hel');
expect(truncateText('hello', 2)).toBe('he');
});
it('breaks at newline and returns only the first line', () => {
expect(truncateText('hello\nworld', 20)).toBe('hello');
expect(truncateText('hello\r\nworld', 20)).toBe('hello');
});
it('breaks at newline and still truncates the first line when needed', () => {
expect(truncateText('hello\nworld', 2)).toBe('he');
expect(truncateText('hello\nworld', 3)).toBe('hel');
expect(truncateText('hello\nworld', 4)).toBe('h...');
});
it('does not add ellipsis when the string ends at a newline', () => {
expect(truncateText('hello\n', 20)).toBe('hello');
expect(truncateText('hello\r\n', 20)).toBe('hello');
});
it('returns only the first line even if there are multiple line breaks', () => {
expect(truncateText('hello\n\nworld', 20)).toBe('hello');
});
});
});

View File

@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import type { SessionListItem } from '@qwen-code/qwen-code-core';
/**
* State for managing loaded sessions in the session picker.
*/
export interface SessionState {
sessions: SessionListItem[];
hasMore: boolean;
nextCursor?: number;
}
/**
* Page size for loading sessions.
*/
export const SESSION_PAGE_SIZE = 20;
/**
* Truncates text to fit within a given width, adding ellipsis if needed.
*/
export function truncateText(text: string, maxWidth: number): string {
const firstLine = text.split(/\r?\n/, 1)[0];
if (firstLine.length <= maxWidth) {
return firstLine;
}
if (maxWidth <= 3) {
return firstLine.slice(0, maxWidth);
}
return firstLine.slice(0, maxWidth - 3) + '...';
}
/**
* Filters sessions optionally by branch.
*/
export function filterSessions(
sessions: SessionListItem[],
filterByBranch: boolean,
currentBranch?: string,
): SessionListItem[] {
return sessions.filter((session) => {
// Apply branch filter if enabled
if (filterByBranch && currentBranch) {
return session.gitBranch === currentBranch;
}
return true;
});
}
/**
* Formats message count for display with proper pluralization.
*/
export function formatMessageCount(count: number): string {
return count === 1 ? '1 message' : `${count} messages`;
}

View File

@@ -76,6 +76,105 @@ describe('getGitHubRepoInfo', async () => {
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
// Tests for credential formats
it('returns the owner and repo for URL with classic PAT token (ghp_)', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
it('returns the owner and repo for URL with fine-grained PAT token (github_pat_)', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://github_pat_xxxxxxxxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
it('returns the owner and repo for URL with username:password format', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://username:password@github.com/owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
it('returns the owner and repo for URL with OAuth token (oauth2:token)', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://oauth2:gho_xxxxxxxxxxxx@github.com/owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
it('returns the owner and repo for URL with GitHub Actions token (x-access-token)', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://x-access-token:ghs_xxxxxxxxxxxx@github.com/owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
// Tests for case insensitivity
it('returns the owner and repo for URL with uppercase GITHUB.COM', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://GITHUB.COM/owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
it('returns the owner and repo for URL with mixed case GitHub.Com', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://GitHub.Com/owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
// Tests for SSH format
it('returns the owner and repo for SSH URL', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'git@github.com:owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
it('throws for non-GitHub SSH URL', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'git@gitlab.com:owner/repo.git',
);
expect(() => {
getGitHubRepoInfo();
}).toThrowError(/Owner & repo could not be extracted from remote URL/);
});
// Tests for edge cases
it('returns the owner and repo for URL without .git suffix', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://github.com/owner/repo',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
it('throws for non-GitHub HTTPS URL', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://gitlab.com/owner/repo.git',
);
expect(() => {
getGitHubRepoInfo();
}).toThrowError(/Owner & repo could not be extracted from remote URL/);
});
it('handles repo names containing .git substring', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://github.com/owner/my.git.repo.git',
);
expect(getGitHubRepoInfo()).toEqual({
owner: 'owner',
repo: 'my.git.repo',
});
});
});
describe('getGitRepoRoot', async () => {

View File

@@ -103,17 +103,38 @@ export function getGitHubRepoInfo(): { owner: string; repo: string } {
encoding: 'utf-8',
}).trim();
// Matches either https://github.com/owner/repo.git or git@github.com:owner/repo.git
const match = remoteUrl.match(
/(?:https?:\/\/|git@)github\.com(?::|\/)([^/]+)\/([^/]+?)(?:\.git)?$/,
);
// If the regex fails match, throw an error.
if (!match || !match[1] || !match[2]) {
// Handle SCP-style SSH URLs (git@github.com:owner/repo.git)
let urlToParse = remoteUrl;
if (remoteUrl.startsWith('git@github.com:')) {
urlToParse = remoteUrl.replace('git@github.com:', '');
} else if (remoteUrl.startsWith('git@')) {
// SSH URL for a different provider (GitLab, Bitbucket, etc.)
throw new Error(
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
);
}
return { owner: match[1], repo: match[2] };
let parsedUrl: URL;
try {
parsedUrl = new URL(urlToParse, 'https://github.com');
} catch {
throw new Error(
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
);
}
if (parsedUrl.host !== 'github.com') {
throw new Error(
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
);
}
const parts = parsedUrl.pathname.split('/').filter((part) => part !== '');
if (parts.length !== 2 || !parts[0] || !parts[1]) {
throw new Error(
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
);
}
return { owner: parts[0], repo: parts[1].replace(/\.git$/, '') };
}

View File

@@ -115,7 +115,7 @@ describe('relaunchAppInChildProcess', () => {
vi.clearAllMocks();
process.env = { ...originalEnv };
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
delete process.env['QWEN_CODE_NO_RELAUNCH'];
process.execArgv = [...originalExecArgv];
process.argv = [...originalArgv];
@@ -145,9 +145,9 @@ describe('relaunchAppInChildProcess', () => {
stdinResumeSpy.mockRestore();
});
describe('when GEMINI_CLI_NO_RELAUNCH is set', () => {
describe('when QWEN_CODE_NO_RELAUNCH is set', () => {
it('should return early without spawning a child process', async () => {
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
await relaunchAppInChildProcess(['--test'], ['--verbose']);
@@ -156,9 +156,9 @@ describe('relaunchAppInChildProcess', () => {
});
});
describe('when GEMINI_CLI_NO_RELAUNCH is not set', () => {
describe('when QWEN_CODE_NO_RELAUNCH is not set', () => {
beforeEach(() => {
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
delete process.env['QWEN_CODE_NO_RELAUNCH'];
});
it('should construct correct node arguments from execArgv, additionalNodeArgs, script, additionalScriptArgs, and argv', () => {

View File

@@ -27,7 +27,7 @@ export async function relaunchAppInChildProcess(
additionalNodeArgs: string[],
additionalScriptArgs: string[],
) {
if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
if (process.env['QWEN_CODE_NO_RELAUNCH']) {
return;
}
@@ -44,7 +44,7 @@ export async function relaunchAppInChildProcess(
...additionalScriptArgs,
...scriptArgs,
];
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
const newEnv = { ...process.env, QWEN_CODE_NO_RELAUNCH: 'true' };
// The parent process should not be reading from stdin while the child is running.
process.stdin.pause();

View File

@@ -287,7 +287,7 @@ export interface ConfigParameters {
contextFileName?: string | string[];
accessibility?: AccessibilitySettings;
telemetry?: TelemetrySettings;
gitCoAuthor?: GitCoAuthorSettings;
gitCoAuthor?: boolean;
usageStatisticsEnabled?: boolean;
fileFiltering?: {
respectGitIgnore?: boolean;
@@ -534,9 +534,9 @@ export class Config {
useCollector: params.telemetry?.useCollector,
};
this.gitCoAuthor = {
enabled: params.gitCoAuthor?.enabled ?? true,
name: params.gitCoAuthor?.name ?? 'Qwen-Coder',
email: params.gitCoAuthor?.email ?? 'qwen-coder@alibabacloud.com',
enabled: params.gitCoAuthor ?? true,
name: 'Qwen-Coder',
email: 'qwen-coder@alibabacloud.com',
};
this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true;
@@ -741,9 +741,12 @@ export class Config {
/**
* Starts a new session and resets session-scoped services.
*/
startNewSession(sessionId?: string): string {
startNewSession(
sessionId?: string,
sessionData?: ResumedSessionData,
): string {
this.sessionId = sessionId ?? randomUUID();
this.sessionData = undefined;
this.sessionData = sessionData;
this.chatRecordingService = this.chatRecordingEnabled
? new ChatRecordingService(this)
: undefined;

View File

@@ -76,6 +76,8 @@ export type ContentGeneratorConfig = {
};
proxy?: string | undefined;
userAgent?: string;
// Schema compliance mode for tool definitions
schemaCompliance?: 'auto' | 'openapi_30';
};
export function createContentGeneratorConfig(

View File

@@ -22,6 +22,10 @@ import { GenerateContentResponse, FinishReason } from '@google/genai';
import type OpenAI from 'openai';
import { safeJsonParse } from '../../utils/safeJsonParse.js';
import { StreamingToolCallParser } from './streamingToolCallParser.js';
import {
convertSchema,
type SchemaComplianceMode,
} from '../../utils/schemaConverter.js';
/**
* Extended usage type that supports both OpenAI standard format and alternative formats
@@ -80,11 +84,13 @@ interface ParsedParts {
*/
export class OpenAIContentConverter {
private model: string;
private schemaCompliance: SchemaComplianceMode;
private streamingToolCallParser: StreamingToolCallParser =
new StreamingToolCallParser();
constructor(model: string) {
constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') {
this.model = model;
this.schemaCompliance = schemaCompliance;
}
/**
@@ -205,6 +211,10 @@ export class OpenAIContentConverter {
);
}
if (parameters) {
parameters = convertSchema(parameters, this.schemaCompliance);
}
openAITools.push({
type: 'function',
function: {

View File

@@ -108,7 +108,10 @@ describe('ContentGenerationPipeline', () => {
describe('constructor', () => {
it('should initialize with correct configuration', () => {
expect(mockProvider.buildClient).toHaveBeenCalled();
expect(OpenAIContentConverter).toHaveBeenCalledWith('test-model');
expect(OpenAIContentConverter).toHaveBeenCalledWith(
'test-model',
undefined,
);
});
});

View File

@@ -34,6 +34,7 @@ export class ContentGenerationPipeline {
this.client = this.config.provider.buildClient();
this.converter = new OpenAIContentConverter(
this.contentGeneratorConfig.model,
this.contentGeneratorConfig.schemaCompliance,
);
}

View File

@@ -608,6 +608,36 @@ describe('ShellTool', () => {
);
});
it('should handle git commit with combined short flags like -am', async () => {
const command = 'git commit -am "Add feature"';
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});
await promise;
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining(
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
false,
{},
);
});
it('should not modify non-git commands', async () => {
const command = 'npm install';
const invocation = shellTool.build({ command, is_background: false });
@@ -768,6 +798,69 @@ describe('ShellTool', () => {
{},
);
});
it('should add co-author when git commit is prefixed with cd command', async () => {
const command = 'cd /tmp/test && git commit -m "Test commit"';
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});
await promise;
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining(
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
false,
{},
);
});
it('should add co-author to git commit with multi-line message', async () => {
const command = `git commit -m "Fix bug
This is a detailed description
spanning multiple lines"`;
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});
await promise;
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining(
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
false,
{},
);
});
});
});

View File

@@ -334,13 +334,14 @@ export class ShellToolInvocation extends BaseToolInvocation<
private addCoAuthorToGitCommit(command: string): string {
// Check if co-author feature is enabled
const gitCoAuthorSettings = this.config.getGitCoAuthor();
if (!gitCoAuthorSettings.enabled) {
return command;
}
// Check if this is a git commit command
const gitCommitPattern = /^git\s+commit/;
if (!gitCommitPattern.test(command.trim())) {
// Check if this is a git commit command (anywhere in the command, e.g., after "cd /path &&")
const gitCommitPattern = /\bgit\s+commit\b/;
if (!gitCommitPattern.test(command)) {
return command;
}
@@ -349,15 +350,27 @@ export class ShellToolInvocation extends BaseToolInvocation<
Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`;
// Handle different git commit patterns
// Match -m "message" or -m 'message'
const messagePattern = /(-m\s+)(['"])((?:\\.|[^\\])*?)(\2)/;
const match = command.match(messagePattern);
// Handle different git commit patterns:
// Match -m "message" or -m 'message', including combined flags like -am
// Use separate patterns to avoid ReDoS (catastrophic backtracking)
//
// Pattern breakdown:
// -[a-zA-Z]*m matches -m, -am, -nm, etc. (combined short flags)
// \s+ matches whitespace after the flag
// [^"\\] matches any char except double-quote and backslash
// \\. matches escape sequences like \" or \\
// (?:...|...)* matches normal chars or escapes, repeated
const doubleQuotePattern = /(-[a-zA-Z]*m\s+)"((?:[^"\\]|\\.)*)"/;
const singleQuotePattern = /(-[a-zA-Z]*m\s+)'((?:[^'\\]|\\.)*)'/;
const doubleMatch = command.match(doubleQuotePattern);
const singleMatch = command.match(singleQuotePattern);
const match = doubleMatch ?? singleMatch;
const quote = doubleMatch ? '"' : "'";
if (match) {
const [fullMatch, prefix, quote, existingMessage, closingQuote] = match;
const [fullMatch, prefix, existingMessage] = match;
const newMessage = existingMessage + coAuthor;
const replacement = prefix + quote + newMessage + closingQuote;
const replacement = prefix + quote + newMessage + quote;
return command.replace(fullMatch, replacement);
}

View File

@@ -0,0 +1,118 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { convertSchema } from './schemaConverter.js';
describe('convertSchema', () => {
describe('mode: auto (default)', () => {
it('should preserve type arrays', () => {
const input = { type: ['string', 'null'] };
expect(convertSchema(input, 'auto')).toEqual(input);
});
it('should preserve items array (tuples)', () => {
const input = {
type: 'array',
items: [{ type: 'string' }, { type: 'number' }],
};
expect(convertSchema(input, 'auto')).toEqual(input);
});
it('should preserve mixed enums', () => {
const input = { enum: [1, 2, '3'] };
expect(convertSchema(input, 'auto')).toEqual(input);
});
it('should preserve unsupported keywords', () => {
const input = {
$schema: 'http://json-schema.org/draft-07/schema#',
exclusiveMinimum: 10,
type: 'number',
};
expect(convertSchema(input, 'auto')).toEqual(input);
});
});
describe('mode: openapi_30 (strict)', () => {
it('should convert type arrays to nullable', () => {
const input = { type: ['string', 'null'] };
const expected = { type: 'string', nullable: true };
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
it('should fallback to first type for non-nullable arrays', () => {
const input = { type: ['string', 'number'] };
const expected = { type: 'string' };
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
it('should convert const to enum', () => {
const input = { const: 'foo' };
const expected = { enum: ['foo'] };
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
it('should convert exclusiveMinimum number to boolean', () => {
const input = { type: 'number', exclusiveMinimum: 10 };
const expected = {
type: 'number',
minimum: 10,
exclusiveMinimum: true,
};
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
it('should convert nested objects recursively', () => {
const input = {
type: 'object',
properties: {
prop1: { type: ['integer', 'null'], exclusiveMaximum: 5 },
},
};
const expected = {
type: 'object',
properties: {
prop1: {
type: 'integer',
nullable: true,
maximum: 5,
exclusiveMaximum: true,
},
},
};
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
it('should stringify enums', () => {
const input = { enum: [1, 2, '3'] };
const expected = { enum: ['1', '2', '3'] };
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
it('should remove tuple items (array of schemas)', () => {
const input = {
type: 'array',
items: [{ type: 'string' }, { type: 'number' }],
};
const expected = { type: 'array' };
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
it('should remove unsupported keywords', () => {
const input = {
$schema: 'http://json-schema.org/draft-07/schema#',
$id: '#foo',
type: 'string',
default: 'bar',
dependencies: { foo: ['bar'] },
patternProperties: { '^foo': { type: 'string' } },
};
const expected = { type: 'string' };
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
});
});

View File

@@ -0,0 +1,135 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Utility for converting JSON Schemas to be compatible with different LLM providers.
* Specifically focuses on downgrading modern JSON Schema (Draft 7/2020-12) to
* OpenAPI 3.0 compatible Schema Objects, which is required for Google Gemini API.
*/
export type SchemaComplianceMode = 'auto' | 'openapi_30';
/**
* Converts a JSON Schema to be compatible with the specified compliance mode.
*/
export function convertSchema(
schema: Record<string, unknown>,
mode: SchemaComplianceMode = 'auto',
): Record<string, unknown> {
if (mode === 'openapi_30') {
return toOpenAPI30(schema);
}
// Default ('auto') mode now does nothing.
return schema;
}
/**
* Converts Modern JSON Schema to OpenAPI 3.0 Schema Object.
* Attempts to preserve semantics where possible through transformations.
*/
function toOpenAPI30(schema: Record<string, unknown>): Record<string, unknown> {
const convert = (obj: unknown): unknown => {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(convert);
}
const source = obj as Record<string, unknown>;
const target: Record<string, unknown> = {};
// 1. Type Handling
if (Array.isArray(source['type'])) {
const types = source['type'] as string[];
// Handle ["string", "null"] pattern common in modern schemas
if (types.length === 2 && types.includes('null')) {
target['type'] = types.find((t) => t !== 'null');
target['nullable'] = true;
} else {
// Fallback for other unions: take the first non-null type
// OpenAPI 3.0 doesn't support type arrays.
// Ideal fix would be anyOf, but simple fallback is safer for now.
target['type'] = types[0];
}
} else if (source['type'] !== undefined) {
target['type'] = source['type'];
}
// 2. Const Handling (Draft 6+) -> Enum (OpenAPI 3.0)
if (source['const'] !== undefined) {
target['enum'] = [source['const']];
delete target['const'];
}
// 3. Exclusive Limits (Draft 6+ number) -> (Draft 4 boolean)
// exclusiveMinimum: 10 -> minimum: 10, exclusiveMinimum: true
if (typeof source['exclusiveMinimum'] === 'number') {
target['minimum'] = source['exclusiveMinimum'];
target['exclusiveMinimum'] = true;
}
if (typeof source['exclusiveMaximum'] === 'number') {
target['maximum'] = source['exclusiveMaximum'];
target['exclusiveMaximum'] = true;
}
// 4. Array Items (Tuple -> Single Schema)
// OpenAPI 3.0 items must be a schema object, not an array of schemas
if (Array.isArray(source['items'])) {
// Tuple support is tricky.
// Best effort: Use the first item's schema as a generic array type
// or convert to an empty object (any type) if mixed.
// For now, we'll strip it to allow validation to pass (accepts any items)
// This matches the legacy behavior but is explicit.
// Ideally, we could use `oneOf` on the items if we wanted to be stricter.
delete target['items'];
} else if (
typeof source['items'] === 'object' &&
source['items'] !== null
) {
target['items'] = convert(source['items']);
}
// 5. Enum Stringification
// Gemini strictly requires enums to be strings
if (Array.isArray(source['enum'])) {
target['enum'] = source['enum'].map(String);
}
// 6. Recursively process other properties
for (const [key, value] of Object.entries(source)) {
// Skip fields we've already handled or want to remove
if (
key === 'type' ||
key === 'const' ||
key === 'exclusiveMinimum' ||
key === 'exclusiveMaximum' ||
key === 'items' ||
key === 'enum' ||
key === '$schema' ||
key === '$id' ||
key === 'default' || // Optional: Gemini sometimes complains about defaults conflicting with types
key === 'dependencies' ||
key === 'patternProperties'
) {
continue;
}
target[key] = convert(value);
}
// Preserve default if it doesn't conflict (simple pass-through)
// if (source['default'] !== undefined) {
// target['default'] = source['default'];
// }
return target;
};
return convert(schema) as Record<string, unknown>;
}

View File

@@ -67,7 +67,7 @@ const env = {
if (process.env.DEBUG) {
// If this is not set, the debugger will pause on the outer process rather
// than the relaunched process making it harder to debug.
env.GEMINI_CLI_NO_RELAUNCH = 'true';
env.QWEN_CODE_NO_RELAUNCH = 'true';
}
// Use process.cwd() to inherit the working directory from launch.json cwd setting
// This allows debugging from a specific directory (e.g., .todo)