mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-20 15:56:19 +00:00
Compare commits
7 Commits
mingholy/f
...
feat/suppo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9328fa478 | ||
|
|
a14d1e27bb | ||
|
|
c4e6c096dc | ||
|
|
4857f2f803 | ||
|
|
5a907c3415 | ||
|
|
d1d215b82e | ||
|
|
a67a8d0277 |
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -13,5 +13,10 @@
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"vitest.disableWorkspaceWarning": true
|
||||
"vitest.disableWorkspaceWarning": true,
|
||||
"lsp": {
|
||||
"enabled": true,
|
||||
"allowed": ["typescript-language-server"],
|
||||
"excluded": ["gopls"]
|
||||
}
|
||||
}
|
||||
|
||||
147
cclsp-integration-plan.md
Normal file
147
cclsp-integration-plan.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Qwen Code CLI LSP 集成实现方案分析
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
本方案旨在将 LSP(Language Server Protocol)能力原生集成到 Qwen Code CLI 中,使 AI 代理能够利用代码导航、定义查找、引用查找等功能。LSP 将作为与 MCP 并行的一级扩展机制实现。
|
||||
|
||||
## 2. 技术方案对比
|
||||
|
||||
### 2.1 Piebald-AI/claude-code-lsps 方案
|
||||
- **架构**: 客户端直接与每个 LSP 通信,通过 `.lsp.json` 配置文件声明服务器命令/参数、stdio 传输和文件扩展名路由
|
||||
- **用户配置**: 低摩擦,只需放置 `.lsp.json` 配置并确保 LSP 二进制文件已安装
|
||||
- **安全**: LSP 子进程以用户权限运行,无内置信任门控
|
||||
- **功能覆盖**: 可以暴露完整的 LSP 表面(hover、诊断、代码操作、重命名等)
|
||||
|
||||
### 2.2 原生 LSP 客户端方案(推荐方案)
|
||||
- **架构**: Qwen Code CLI 直接作为 LSP 客户端,与语言服务器建立 JSON-RPC 连接
|
||||
- **用户配置**: 支持内置预设 + 用户自定义 `.lsp.json` 配置
|
||||
- **安全**: 与 MCP 共享相同的安全控制(信任工作区、允许/拒绝列表、确认提示)
|
||||
- **功能覆盖**: 暴露完整的 LSP 功能(流式诊断、代码操作、重命名、语义标记等)
|
||||
|
||||
### 2.3 cclsp + MCP 方案(备选)
|
||||
- **架构**: 通过 MCP 协议调用 cclsp 作为 LSP 桥接
|
||||
- **用户配置**: 需要 MCP 配置
|
||||
- **安全**: 通过 MCP 安全控制
|
||||
- **功能覆盖**: 依赖于 cclsp 映射的 MCP 工具
|
||||
|
||||
## 3. 原生 LSP 集成详细计划
|
||||
|
||||
### 3.1 方案选择
|
||||
- **推荐方案**: 原生 LSP 客户端作为主要路径,因为它提供完整 LSP 功能、更低延迟和更好的用户体验
|
||||
- **兼容层**: 保留 cclsp+MCP 作为现有 MCP 工作流的兼容桥接
|
||||
- **并行架构**: LSP 和 MCP 作为独立的扩展机制共存,共享安全策略
|
||||
|
||||
### 3.2 实现步骤
|
||||
|
||||
#### 3.2.1 创建原生 LSP 服务
|
||||
在 `packages/cli/src/services/lsp/` 目录下创建 `NativeLspService` 类,处理:
|
||||
- 工作区语言检测
|
||||
- 自动发现和启动语言服务器
|
||||
- 与现有文档/编辑模型同步
|
||||
- LSP 能力直接暴露给代理
|
||||
|
||||
#### 3.2.2 配置支持
|
||||
- 支持内置预设配置(常见语言服务器)
|
||||
- 支持用户自定义 `.lsp.json` 配置文件
|
||||
- 与 MCP 配置共存,共享信任控制
|
||||
|
||||
#### 3.2.3 集成启动流程
|
||||
- 在 `packages/cli/src/config/config.ts` 中的 `loadCliConfig` 函数内集成
|
||||
- 确保 LSP 服务与 MCP 服务共享相同的安全控制机制
|
||||
- 处理沙箱预检和主运行的重复调用问题
|
||||
|
||||
#### 3.2.4 功能标志配置
|
||||
- 在 `packages/cli/src/config/settingsSchema.ts` 中添加新的设置项
|
||||
- 提供全局开关(如 `lsp.enabled=false`)允许用户禁用 LSP 功能
|
||||
- 尊重 `mcp.allowed`/`mcp.excluded` 和文件夹信任设置
|
||||
|
||||
#### 3.2.5 安全控制
|
||||
- 与 MCP 共享相同的安全控制机制
|
||||
- 在信任工作区中自动启用,在非信任工作区中提示用户
|
||||
- 实现路径允许列表和进程启动确认
|
||||
|
||||
#### 3.2.6 错误处理与用户通知
|
||||
- 检测缺失的语言服务器并提供安装命令
|
||||
- 通过现有 MCP 状态 UI 显示错误信息
|
||||
- 实现重试/退避机制,检测沙箱环境并抑制自动启动
|
||||
|
||||
### 3.3 需要确认的不确定项
|
||||
|
||||
1. **启动集成点**:在 `loadCliConfig` 中集成原生 LSP 服务,需确保与 MCP 服务的协调
|
||||
|
||||
2. **配置优先级**:如果用户已有 cclsp MCP 配置,应保持并存还是优先使用原生 LSP
|
||||
|
||||
3. **功能开关设计**:开关应该是全局级别的,LSP 和 MCP 可独立启用/禁用
|
||||
|
||||
4. **共享安全模型**:如何在代码中复用 MCP 的信任/安全控制逻辑
|
||||
|
||||
5. **语言服务器管理**:如何管理 LSP 服务器生命周期并与文档编辑模型同步
|
||||
|
||||
6. **依赖检测机制**:检测 LSP 服务器可用性,失败时提供降级选项
|
||||
|
||||
7. **测试策略**:需要测试 LSP 与 MCP 的并行运行,以及共享安全控制
|
||||
|
||||
### 3.4 安全考虑
|
||||
|
||||
- 与 MCP 共享相同的安全控制模型
|
||||
- 仅在受信任工作区中启用自动 LSP 功能
|
||||
- 提供用户确认机制用于启动新的 LSP 服务器
|
||||
- 防止路径劫持,使用安全的路径解析
|
||||
|
||||
### 3.5 高级 LSP 功能支持
|
||||
|
||||
- **完整 LSP 功能**: 支持流式诊断、代码操作、重命名、语义高亮、工作区编辑等
|
||||
- **兼容 Claude 配置**: 支持导入 Claude Code 风格的 `.lsp.json` 配置
|
||||
- **性能优化**: 优化 LSP 服务器启动时间和内存使用
|
||||
|
||||
### 3.6 用户体验
|
||||
|
||||
- 提供安装提示而非自动安装
|
||||
- 在统一的状态界面显示 LSP 和 MCP 服务器状态
|
||||
- 提供独立开关让用户控制 LSP 和 MCP 功能
|
||||
- 为只读/沙箱环境提供安全的配置处理和清晰的错误消息
|
||||
|
||||
## 4. 实施总结
|
||||
|
||||
### 4.1 已完成的工作
|
||||
1. **NativeLspService 类**:创建了核心服务类,包含语言检测、配置合并、LSP 连接管理等功能
|
||||
2. **LSP 连接工厂**:实现了基于 stdio 的 LSP 连接创建和管理
|
||||
3. **语言检测机制**:实现了基于文件扩展名和项目配置文件的语言自动检测
|
||||
4. **配置系统**:实现了内置预设、用户配置和 Claude 兼容配置的合并
|
||||
5. **安全控制**:实现了与 MCP 共享的安全控制机制,包括信任检查、用户确认、路径安全验证
|
||||
6. **CLI 集成**:在 `loadCliConfig` 函数中集成了 LSP 服务初始化点
|
||||
|
||||
### 4.2 关键组件
|
||||
|
||||
#### 4.2.1 LspConnectionFactory
|
||||
- 使用 `vscode-jsonrpc` 和 `vscode-languageserver-protocol` 实现 LSP 连接
|
||||
- 支持 stdio 传输方式,可以扩展支持 TCP 传输
|
||||
- 提供连接创建、初始化和关闭的完整生命周期管理
|
||||
|
||||
#### 4.2.2 NativeLspService
|
||||
- **语言检测**:扫描项目文件和配置文件来识别编程语言
|
||||
- **配置合并**:按优先级合并内置预设、用户配置和兼容层配置
|
||||
- **LSP 服务器管理**:启动、停止和状态管理
|
||||
- **安全控制**:与 MCP 共享的信任和确认机制
|
||||
|
||||
#### 4.2.3 配置架构
|
||||
- **内置预设**:为常见语言提供默认 LSP 服务器配置
|
||||
- **用户配置**:支持 `.lsp.json` 文件格式
|
||||
- **Claude 兼容**:可导入 Claude Code 的 LSP 配置
|
||||
|
||||
### 4.3 依赖管理
|
||||
- 使用 `vscode-languageserver-protocol` 进行 LSP 协议通信
|
||||
- 使用 `vscode-jsonrpc` 进行 JSON-RPC 消息传递
|
||||
- 使用 `vscode-languageserver-textdocument` 管理文档版本
|
||||
|
||||
### 4.4 安全特性
|
||||
- 工作区信任检查
|
||||
- 用户确认机制(对于非信任工作区)
|
||||
- 命令存在性验证
|
||||
- 路径安全性检查
|
||||
|
||||
## 5. 总结
|
||||
|
||||
原生 LSP 客户端是当前最符合 Qwen Code 架构的选择,它提供了完整的 LSP 功能、更低的延迟和更好的用户体验。LSP 作为与 MCP 并行的一级扩展机制,将与 MCP 共享安全控制策略,但提供更丰富的代码智能功能。cclsp+MCP 可作为兼容层保留,以支持现有的 MCP 工作流。
|
||||
|
||||
该实现方案将使 Qwen Code CLI 具备完整的 LSP 功能,包括代码跳转、引用查找、自动补全、代码诊断等,为 AI 代理提供更丰富的代码理解能力。
|
||||
@@ -5,13 +5,11 @@ Qwen Code supports two authentication methods. Pick the one that matches how you
|
||||
- **Qwen OAuth (recommended)**: sign in with your `qwen.ai` account in a browser.
|
||||
- **OpenAI-compatible API**: use an API key (OpenAI or any OpenAI-compatible provider / endpoint).
|
||||
|
||||

|
||||
|
||||
## Option 1: Qwen OAuth (recommended & free) 👍
|
||||
|
||||
Use this if you want the simplest setup and you're using Qwen models.
|
||||
Use this if you want the simplest setup and you’re using Qwen models.
|
||||
|
||||
- **How it works**: on first start, Qwen Code opens a browser login page. After you finish, credentials are cached locally so you usually won't need to log in again.
|
||||
- **How it works**: on first start, Qwen Code opens a browser login page. After you finish, credentials are cached locally so you usually won’t need to log in again.
|
||||
- **Requirements**: a `qwen.ai` account + internet access (at least for the first login).
|
||||
- **Benefits**: no API key management, automatic credential refresh.
|
||||
- **Cost & quota**: free, with a quota of **60 requests/minute** and **2,000 requests/day**.
|
||||
@@ -26,54 +24,15 @@ qwen
|
||||
|
||||
Use this if you want to use OpenAI models or any provider that exposes an OpenAI-compatible API (e.g. OpenAI, Azure OpenAI, OpenRouter, ModelScope, Alibaba Cloud Bailian, or a self-hosted compatible endpoint).
|
||||
|
||||
### Recommended: Coding Plan (subscription-based) 🚀
|
||||
### Quick start (interactive, recommended for local use)
|
||||
|
||||
Use this if you want predictable costs with higher usage quotas for the qwen3-coder-plus model.
|
||||
When you choose the OpenAI-compatible option in the CLI, it will prompt you for:
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> Coding Plan is only available for users in China mainland (Beijing region).
|
||||
- **API key**
|
||||
- **Base URL** (default: `https://api.openai.com/v1`)
|
||||
- **Model** (default: `gpt-4o`)
|
||||
|
||||
- **How it works**: subscribe to the Coding Plan with a fixed monthly fee, then configure Qwen Code to use the dedicated endpoint and your subscription API key.
|
||||
- **Requirements**: an active Coding Plan subscription from [Alibaba Cloud Bailian](https://bailian.console.aliyun.com/cn-beijing/?tab=globalset#/efm/coding_plan).
|
||||
- **Benefits**: higher usage quotas, predictable monthly costs, access to latest qwen3-coder-plus model.
|
||||
- **Cost & quota**: varies by plan (see table below).
|
||||
|
||||
#### Coding Plan Pricing & Quotas
|
||||
|
||||
| Feature | Lite Basic Plan | Pro Advanced Plan |
|
||||
| :------------------ | :-------------------- | :-------------------- |
|
||||
| **Price** | ¥40/month | ¥200/month |
|
||||
| **5-Hour Limit** | Up to 1,200 requests | Up to 6,000 requests |
|
||||
| **Weekly Limit** | Up to 9,000 requests | Up to 45,000 requests |
|
||||
| **Monthly Limit** | Up to 18,000 requests | Up to 90,000 requests |
|
||||
| **Supported Model** | qwen3-coder-plus | qwen3-coder-plus |
|
||||
|
||||
#### Quick Setup for Coding Plan
|
||||
|
||||
When you select the OpenAI-compatible option in the CLI, enter these values:
|
||||
|
||||
- **API key**: `sk-sp-xxxxx`
|
||||
- **Base URL**: `https://coding.dashscope.aliyuncs.com/v1`
|
||||
- **Model**: `qwen3-coder-plus`
|
||||
|
||||
> **Note**: Coding Plan API keys have the format `sk-sp-xxxxx`, which is different from standard Alibaba Cloud API keys.
|
||||
|
||||
#### Configure via Environment Variables
|
||||
|
||||
Set these environment variables to use Coding Plan:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-coding-plan-api-key" # Format: sk-sp-xxxxx
|
||||
export OPENAI_BASE_URL="https://coding.dashscope.aliyuncs.com/v1"
|
||||
export OPENAI_MODEL="qwen3-coder-plus"
|
||||
```
|
||||
|
||||
For more details about Coding Plan, including subscription options and troubleshooting, see the [full Coding Plan documentation](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3005961).
|
||||
|
||||
### Other OpenAI-compatible Providers
|
||||
|
||||
If you are using other providers (OpenAI, Azure, local LLMs, etc.), use the following configuration methods.
|
||||
> **Note:** the CLI may display the key in plain text for verification. Make sure your terminal is not being recorded or shared.
|
||||
|
||||
### Configure via command-line arguments
|
||||
|
||||
|
||||
@@ -74,6 +74,9 @@ Settings are organized into categories. All settings should be placed within the
|
||||
| `ui.customThemes` | object | Custom theme definitions. | `{}` |
|
||||
| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` |
|
||||
| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` |
|
||||
| `ui.hideBanner` | boolean | Hide the application banner. | `false` |
|
||||
| `ui.hideFooter` | boolean | Hide the footer from the UI. | `false` |
|
||||
| `ui.showMemoryUsage` | boolean | Display memory usage information in the UI. | `false` |
|
||||
| `ui.showLineNumbers` | boolean | Show line numbers in code blocks in the CLI output. | `true` |
|
||||
| `ui.showCitations` | boolean | Show citations for generated text in the chat. | `true` |
|
||||
| `enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` |
|
||||
@@ -238,6 +241,7 @@ Per-field precedence for `generationConfig`:
|
||||
| ------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `context.fileName` | string or array of strings | The name of the context file(s). | `undefined` |
|
||||
| `context.importFormat` | string | The format to use when importing memory. | `undefined` |
|
||||
| `context.discoveryMaxDirs` | number | Maximum number of directories to search for memory. | `200` |
|
||||
| `context.includeDirectories` | array | Additional directories to include in the workspace context. Specifies an array of additional absolute or relative paths to include in the workspace context. Missing directories will be skipped with a warning by default. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag. | `[]` |
|
||||
| `context.loadFromIncludeDirectories` | boolean | Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory. | `false` |
|
||||
| `context.fileFiltering.respectGitIgnore` | boolean | Respect .gitignore files when searching. | `true` |
|
||||
@@ -270,6 +274,8 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
|
||||
| `tools.enableToolOutputTruncation` | boolean | Enable truncation of large tool outputs. | `true` | Requires restart: Yes |
|
||||
| `tools.truncateToolOutputThreshold` | number | Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `25000` | Requires restart: Yes |
|
||||
| `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes |
|
||||
| `tools.autoAccept` | boolean | Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe. | `false` | |
|
||||
| `tools.experimental.skills` | boolean | Enable experimental Agent Skills feature | `false` | |
|
||||
|
||||
#### mcp
|
||||
|
||||
@@ -306,12 +312,6 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
|
||||
>
|
||||
> **Note about advanced.tavilyApiKey:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format.
|
||||
|
||||
#### experimental
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| --------------------- | ------- | -------------------------------- | ------- |
|
||||
| `experimental.skills` | boolean | Enable experimental Agent Skills | `false` |
|
||||
|
||||
#### mcpServers
|
||||
|
||||
Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Qwen Code attempts to connect to each configured MCP server to discover available tools. If multiple MCP servers expose a tool with the same name, the tool names will be prefixed with the server alias you defined in the configuration (e.g., `serverAlias__actualToolName`) to avoid conflicts. Note that the system might strip certain schema properties from MCP tool definitions for compatibility. At least one of `command`, `url`, or `httpUrl` must be provided. If multiple are specified, the order of precedence is `httpUrl`, then `url`, then `command`.
|
||||
@@ -357,6 +357,7 @@ Here is an example of a `settings.json` file with the nested structure, new as o
|
||||
},
|
||||
"ui": {
|
||||
"theme": "GitHub",
|
||||
"hideBanner": true,
|
||||
"hideTips": false,
|
||||
"customWittyPhrases": [
|
||||
"You forget a thousand things every day. Make sure this is one of 'em",
|
||||
@@ -529,13 +530,16 @@ Here's a conceptual example of what a context file at the root of a TypeScript p
|
||||
|
||||
This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context.
|
||||
|
||||
- **Hierarchical Loading and Precedence:** The CLI implements a hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
|
||||
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
|
||||
1. **Global Context File:**
|
||||
- Location: `~/.qwen/<configured-context-filename>` (e.g., `~/.qwen/QWEN.md` in your user home directory).
|
||||
- Scope: Provides default instructions for all your projects.
|
||||
2. **Project Root & Ancestors Context Files:**
|
||||
- Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory.
|
||||
- Scope: Provides context relevant to the entire project or a significant portion of it.
|
||||
3. **Sub-directory Context Files (Contextual/Local):**
|
||||
- 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](../configuration/memory).
|
||||
- **Commands for Memory Management:**
|
||||
|
||||
@@ -20,7 +20,6 @@ This document lists the available keyboard shortcuts in Qwen Code.
|
||||
| Shortcut | Description |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `!` | Toggle shell mode when the input is empty. |
|
||||
| `?` | Toggle keyboard shortcuts display when the input is empty. |
|
||||
| `\` (at end of line) + `Enter` | Insert a newline. |
|
||||
| `Down Arrow` | Navigate down through the input history. |
|
||||
| `Enter` | Submit the current prompt. |
|
||||
@@ -39,7 +38,6 @@ This document lists the available keyboard shortcuts in Qwen Code.
|
||||
| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. |
|
||||
| `Ctrl+N` | Navigate down through the input history. |
|
||||
| `Ctrl+P` | Navigate up through the input history. |
|
||||
| `Ctrl+R` | Reverse search through input/shell history. |
|
||||
| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
|
||||
| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
|
||||
| `Ctrl+V` | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
|
||||
|
||||
@@ -311,9 +311,9 @@ function setupAcpTest(
|
||||
}
|
||||
});
|
||||
|
||||
it('returns modes on initialize and allows setting mode and model', async () => {
|
||||
it('returns modes on initialize and allows setting approval mode', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp mode and model');
|
||||
rig.setup('acp approval mode');
|
||||
|
||||
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
|
||||
|
||||
@@ -366,14 +366,8 @@ function setupAcpTest(
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as {
|
||||
sessionId: string;
|
||||
models: {
|
||||
availableModels: Array<{ modelId: string }>;
|
||||
};
|
||||
};
|
||||
})) as { sessionId: string };
|
||||
expect(newSession.sessionId).toBeTruthy();
|
||||
expect(newSession.models.availableModels.length).toBeGreaterThan(0);
|
||||
|
||||
// Test 4: Set approval mode to 'yolo'
|
||||
const setModeResult = (await sendRequest('session/set_mode', {
|
||||
@@ -398,15 +392,6 @@ function setupAcpTest(
|
||||
})) as { modeId: string };
|
||||
expect(setModeResult3).toBeDefined();
|
||||
expect(setModeResult3.modeId).toBe('default');
|
||||
|
||||
// Test 7: Set model using first available model
|
||||
const firstModel = newSession.models.availableModels[0];
|
||||
const setModelResult = (await sendRequest('session/set_model', {
|
||||
sessionId: newSession.sessionId,
|
||||
modelId: firstModel.modelId,
|
||||
})) as { modelId: string };
|
||||
expect(setModelResult).toBeDefined();
|
||||
expect(setModelResult.modelId).toBeTruthy();
|
||||
} catch (e) {
|
||||
if (stderr.length) {
|
||||
console.error('Agent stderr:', stderr.join(''));
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -39,6 +39,7 @@
|
||||
"globals": "^16.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"json": "^11.0.0",
|
||||
"json-schema": "^0.4.0",
|
||||
"lint-staged": "^16.1.6",
|
||||
"memfs": "^4.42.0",
|
||||
"mnemonist": "^0.40.3",
|
||||
@@ -10804,6 +10805,13 @@
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||
"dev": true,
|
||||
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"globals": "^16.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"json": "^11.0.0",
|
||||
"json-schema": "^0.4.0",
|
||||
"lint-staged": "^16.1.6",
|
||||
"memfs": "^4.42.0",
|
||||
"mnemonist": "^0.40.3",
|
||||
|
||||
140
packages/cli/LSP_DEBUGGING_GUIDE.md
Normal file
140
packages/cli/LSP_DEBUGGING_GUIDE.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# LSP 调试指南
|
||||
|
||||
本指南介绍如何调试 packages/cli 中的 LSP (Language Server Protocol) 功能。
|
||||
|
||||
## 1. 启用调试模式
|
||||
|
||||
CLI 支持调试模式,可以提供额外的日志信息:
|
||||
|
||||
```bash
|
||||
# 使用 debug 标志运行
|
||||
qwen --debug [你的命令]
|
||||
|
||||
# 或设置环境变量
|
||||
DEBUG=true qwen [你的命令]
|
||||
DEBUG_MODE=true qwen [你的命令]
|
||||
```
|
||||
|
||||
## 2. LSP 配置选项
|
||||
|
||||
LSP 功能通过设置系统配置,包含以下选项:
|
||||
|
||||
- `lsp.enabled`: 启用/禁用原生 LSP 客户端(默认为 `false`)
|
||||
- `lsp.allowed`: 允许的 LSP 服务器名称白名单
|
||||
- `lsp.excluded`: 排除的 LSP 服务器名称黑名单
|
||||
|
||||
在 settings.json 中的示例配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"enabled": true,
|
||||
"allowed": ["typescript-language-server", "pylsp"],
|
||||
"excluded": ["gopls"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
也可以在 `settings.json` 中配置 `lsp.languageServers`,格式与 `.lsp.json` 一致。
|
||||
|
||||
## 3. NativeLspService 调试功能
|
||||
|
||||
`NativeLspService` 类包含几个调试功能:
|
||||
|
||||
### 3.1 控制台日志
|
||||
|
||||
服务向控制台输出状态消息:
|
||||
|
||||
- `LSP 服务器 ${name} 启动成功` - 服务器成功启动
|
||||
- `LSP 服务器 ${name} 启动失败` - 服务器启动失败
|
||||
- `工作区不受信任,跳过 LSP 服务器发现` - 工作区不受信任,跳过发现
|
||||
|
||||
### 3.2 错误处理
|
||||
|
||||
服务具有全面的错误处理和详细的错误消息
|
||||
|
||||
### 3.3 状态跟踪
|
||||
|
||||
您可以通过 `getStatus()` 方法检查所有 LSP 服务器的状态
|
||||
|
||||
## 4. 调试命令
|
||||
|
||||
```bash
|
||||
# 启用调试运行
|
||||
qwen --debug --prompt "调试 LSP 功能"
|
||||
|
||||
# 检查在您的项目中检测到哪些 LSP 服务器
|
||||
# 系统会自动检测语言和相应的 LSP 服务器
|
||||
```
|
||||
|
||||
## 5. 手动 LSP 服务器配置
|
||||
|
||||
您还可以在项目根目录使用 `.lsp.json` 文件手动配置 LSP 服务器。
|
||||
推荐使用新格式(以服务器名称为键),旧格式仍然兼容但会提示迁移:
|
||||
|
||||
```json
|
||||
{
|
||||
"languageServers": {
|
||||
"pylsp": {
|
||||
"command": "pylsp",
|
||||
"args": [],
|
||||
"languages": ["python"],
|
||||
"transport": "stdio",
|
||||
"settings": {},
|
||||
"workspaceFolder": null,
|
||||
"startupTimeout": 10000,
|
||||
"shutdownTimeout": 3000,
|
||||
"restartOnCrash": true,
|
||||
"maxRestarts": 3,
|
||||
"trustRequired": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
旧格式示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"python": {
|
||||
"command": "pylsp",
|
||||
"args": [],
|
||||
"transport": "stdio",
|
||||
"trustRequired": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. LSP 问题排查
|
||||
|
||||
### 6.1 检查 LSP 服务器是否已安装
|
||||
|
||||
- 对于 TypeScript/JavaScript: `typescript-language-server`
|
||||
- 对于 Python: `pylsp`
|
||||
- 对于 Go: `gopls`
|
||||
|
||||
### 6.2 验证工作区信任
|
||||
|
||||
- LSP 服务器可能需要受信任的工作区才能启动
|
||||
- 检查 `security.folderTrust.enabled` 设置
|
||||
|
||||
### 6.3 查看日志
|
||||
|
||||
- 查找以 `LSP 服务器` 开头的控制台消息
|
||||
- 检查命令存在性和路径安全性问题
|
||||
|
||||
## 7. LSP 服务启动流程
|
||||
|
||||
LSP 服务的启动遵循以下流程:
|
||||
|
||||
1. **发现和准备**: `discoverAndPrepare()` 方法检测工作区中的编程语言
|
||||
2. **创建服务器句柄**: 根据检测到的语言创建对应的服务器句柄
|
||||
3. **启动服务器**: `start()` 方法启动所有服务器句柄
|
||||
4. **状态管理**: 服务器状态在 `NOT_STARTED`, `IN_PROGRESS`, `READY`, `FAILED` 之间转换
|
||||
|
||||
## 8. 调试技巧
|
||||
|
||||
- 使用 `--debug` 标志查看详细的启动过程
|
||||
- 检查工作区是否受信任(影响 LSP 服务器启动)
|
||||
- 确认 LSP 服务器命令在系统 PATH 中可用
|
||||
- 使用 `getStatus()` 方法监控服务器运行状态
|
||||
@@ -70,13 +70,6 @@ export class AgentSideConnection implements Client {
|
||||
const validatedParams = schema.setModeRequestSchema.parse(params);
|
||||
return agent.setMode(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_set_model: {
|
||||
if (!agent.setModel) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams = schema.setModelRequestSchema.parse(params);
|
||||
return agent.setModel(validatedParams);
|
||||
}
|
||||
default:
|
||||
throw RequestError.methodNotFound(method);
|
||||
}
|
||||
@@ -415,5 +408,4 @@ export interface Agent {
|
||||
prompt(params: schema.PromptRequest): Promise<schema.PromptResponse>;
|
||||
cancel(params: schema.CancelNotification): Promise<void>;
|
||||
setMode?(params: schema.SetModeRequest): Promise<schema.SetModeResponse>;
|
||||
setModel?(params: schema.SetModelRequest): Promise<schema.SetModelResponse>;
|
||||
}
|
||||
|
||||
@@ -165,11 +165,30 @@ class GeminiAgent {
|
||||
this.setupFileSystem(config);
|
||||
|
||||
const session = await this.createAndStoreSession(config);
|
||||
const availableModels = this.buildAvailableModels(config);
|
||||
const configuredModel = (
|
||||
config.getModel() ||
|
||||
this.config.getModel() ||
|
||||
''
|
||||
).trim();
|
||||
const modelId = configuredModel || 'default';
|
||||
const modelName = configuredModel || modelId;
|
||||
|
||||
return {
|
||||
sessionId: session.getId(),
|
||||
models: availableModels,
|
||||
models: {
|
||||
currentModelId: modelId,
|
||||
availableModels: [
|
||||
{
|
||||
modelId,
|
||||
name: modelName,
|
||||
description: null,
|
||||
_meta: {
|
||||
contextLimit: tokenLimit(modelId),
|
||||
},
|
||||
},
|
||||
],
|
||||
_meta: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -286,29 +305,15 @@ class GeminiAgent {
|
||||
async setMode(params: acp.SetModeRequest): Promise<acp.SetModeResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
`Session not found for id: ${params.sessionId}`,
|
||||
);
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
return session.setMode(params);
|
||||
}
|
||||
|
||||
async setModel(params: acp.SetModelRequest): Promise<acp.SetModelResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
`Session not found for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.setModel(params);
|
||||
}
|
||||
|
||||
private async ensureAuthenticated(config: Config): Promise<void> {
|
||||
const selectedType = this.settings.merged.security?.auth?.selectedType;
|
||||
if (!selectedType) {
|
||||
throw acp.RequestError.authRequired(
|
||||
'Use Qwen Code CLI to authenticate first.',
|
||||
);
|
||||
throw acp.RequestError.authRequired('No Selected Type');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -377,43 +382,4 @@ class GeminiAgent {
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private buildAvailableModels(
|
||||
config: Config,
|
||||
): acp.NewSessionResponse['models'] {
|
||||
const currentModelId = (
|
||||
config.getModel() ||
|
||||
this.config.getModel() ||
|
||||
''
|
||||
).trim();
|
||||
const availableModels = config.getAvailableModels();
|
||||
|
||||
const mappedAvailableModels = availableModels.map((model) => ({
|
||||
modelId: model.id,
|
||||
name: model.label,
|
||||
description: model.description ?? null,
|
||||
_meta: {
|
||||
contextLimit: tokenLimit(model.id),
|
||||
},
|
||||
}));
|
||||
|
||||
if (
|
||||
currentModelId &&
|
||||
!mappedAvailableModels.some((model) => model.modelId === currentModelId)
|
||||
) {
|
||||
mappedAvailableModels.unshift({
|
||||
modelId: currentModelId,
|
||||
name: currentModelId,
|
||||
description: null,
|
||||
_meta: {
|
||||
contextLimit: tokenLimit(currentModelId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
currentModelId,
|
||||
availableModels: mappedAvailableModels,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ export const AGENT_METHODS = {
|
||||
session_prompt: 'session/prompt',
|
||||
session_list: 'session/list',
|
||||
session_set_mode: 'session/set_mode',
|
||||
session_set_model: 'session/set_model',
|
||||
};
|
||||
|
||||
export const CLIENT_METHODS = {
|
||||
@@ -267,18 +266,6 @@ export const modelInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const setModelRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
export const setModelResponseSchema = z.object({
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
export type SetModelRequest = z.infer<typeof setModelRequestSchema>;
|
||||
export type SetModelResponse = z.infer<typeof setModelResponseSchema>;
|
||||
|
||||
export const sessionModelStateSchema = z.object({
|
||||
_meta: acpMetaSchema,
|
||||
availableModels: z.array(modelInfoSchema),
|
||||
@@ -605,7 +592,6 @@ export const agentResponseSchema = z.union([
|
||||
promptResponseSchema,
|
||||
listSessionsResponseSchema,
|
||||
setModeResponseSchema,
|
||||
setModelResponseSchema,
|
||||
]);
|
||||
|
||||
export const requestPermissionRequestSchema = z.object({
|
||||
@@ -638,7 +624,6 @@ export const agentRequestSchema = z.union([
|
||||
promptRequestSchema,
|
||||
listSessionsRequestSchema,
|
||||
setModeRequestSchema,
|
||||
setModelRequestSchema,
|
||||
]);
|
||||
|
||||
export const agentNotificationSchema = sessionNotificationSchema;
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Session } from './Session.js';
|
||||
import type { Config, GeminiChat } from '@qwen-code/qwen-code-core';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import type * as acp from '../acp.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js';
|
||||
|
||||
vi.mock('../../nonInteractiveCliCommands.js', () => ({
|
||||
getAvailableCommands: vi.fn(),
|
||||
handleSlashCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Session', () => {
|
||||
let mockChat: GeminiChat;
|
||||
let mockConfig: Config;
|
||||
let mockClient: acp.Client;
|
||||
let mockSettings: LoadedSettings;
|
||||
let session: Session;
|
||||
let currentModel: string;
|
||||
let setModelSpy: ReturnType<typeof vi.fn>;
|
||||
let getAvailableCommandsSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
currentModel = 'qwen3-code-plus';
|
||||
setModelSpy = vi.fn().mockImplementation(async (modelId: string) => {
|
||||
currentModel = modelId;
|
||||
});
|
||||
|
||||
mockChat = {
|
||||
sendMessageStream: vi.fn(),
|
||||
addHistory: vi.fn(),
|
||||
} as unknown as GeminiChat;
|
||||
|
||||
mockConfig = {
|
||||
setApprovalMode: vi.fn(),
|
||||
setModel: setModelSpy,
|
||||
getModel: vi.fn().mockImplementation(() => currentModel),
|
||||
} as unknown as Config;
|
||||
|
||||
mockClient = {
|
||||
sessionUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
requestPermission: vi.fn().mockResolvedValue({
|
||||
outcome: { outcome: 'selected', optionId: 'proceed_once' },
|
||||
}),
|
||||
sendCustomNotification: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as acp.Client;
|
||||
|
||||
mockSettings = {
|
||||
merged: {},
|
||||
} as LoadedSettings;
|
||||
|
||||
getAvailableCommandsSpy = vi.mocked(nonInteractiveCliCommands)
|
||||
.getAvailableCommands as unknown as ReturnType<typeof vi.fn>;
|
||||
getAvailableCommandsSpy.mockResolvedValue([]);
|
||||
|
||||
session = new Session(
|
||||
'test-session-id',
|
||||
mockChat,
|
||||
mockConfig,
|
||||
mockClient,
|
||||
mockSettings,
|
||||
);
|
||||
});
|
||||
|
||||
describe('setMode', () => {
|
||||
it.each([
|
||||
['plan', ApprovalMode.PLAN],
|
||||
['default', ApprovalMode.DEFAULT],
|
||||
['auto-edit', ApprovalMode.AUTO_EDIT],
|
||||
['yolo', ApprovalMode.YOLO],
|
||||
] as const)('maps %s mode', async (modeId, expected) => {
|
||||
const result = await session.setMode({
|
||||
sessionId: 'test-session-id',
|
||||
modeId,
|
||||
});
|
||||
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(expected);
|
||||
expect(result).toEqual({ modeId });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setModel', () => {
|
||||
it('sets model via config and returns current model', async () => {
|
||||
const result = await session.setModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: ' qwen3-coder-plus ',
|
||||
});
|
||||
|
||||
expect(mockConfig.setModel).toHaveBeenCalledWith('qwen3-coder-plus', {
|
||||
reason: 'user_request_acp',
|
||||
context: 'session/set_model',
|
||||
});
|
||||
expect(mockConfig.getModel).toHaveBeenCalled();
|
||||
expect(result).toEqual({ modelId: 'qwen3-coder-plus' });
|
||||
});
|
||||
|
||||
it('rejects empty/whitespace model IDs', async () => {
|
||||
await expect(
|
||||
session.setModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: ' ',
|
||||
}),
|
||||
).rejects.toThrow('Invalid params');
|
||||
|
||||
expect(mockConfig.setModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates errors from config.setModel', async () => {
|
||||
const configError = new Error('Invalid model');
|
||||
setModelSpy.mockRejectedValueOnce(configError);
|
||||
|
||||
await expect(
|
||||
session.setModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: 'invalid-model',
|
||||
}),
|
||||
).rejects.toThrow('Invalid model');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendAvailableCommandsUpdate', () => {
|
||||
it('sends available_commands_update from getAvailableCommands()', async () => {
|
||||
getAvailableCommandsSpy.mockResolvedValueOnce([
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize project context',
|
||||
},
|
||||
]);
|
||||
|
||||
await session.sendAvailableCommandsUpdate();
|
||||
|
||||
expect(getAvailableCommandsSpy).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: 'test-session-id',
|
||||
update: {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize project context',
|
||||
input: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('swallows errors and does not throw', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
getAvailableCommandsSpy.mockRejectedValueOnce(
|
||||
new Error('Command discovery failed'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
session.sendAvailableCommandsUpdate(),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockClient.sessionUpdate).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,8 +52,6 @@ import type {
|
||||
AvailableCommandsUpdate,
|
||||
SetModeRequest,
|
||||
SetModeResponse,
|
||||
SetModelRequest,
|
||||
SetModelResponse,
|
||||
ApprovalModeValue,
|
||||
CurrentModeUpdate,
|
||||
} from '../schema.js';
|
||||
@@ -350,31 +348,6 @@ export class Session implements SessionContext {
|
||||
return { modeId: params.modeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the model for the current session.
|
||||
* Validates the model ID and switches the model via Config.
|
||||
*/
|
||||
async setModel(params: SetModelRequest): Promise<SetModelResponse> {
|
||||
const modelId = params.modelId.trim();
|
||||
|
||||
if (!modelId) {
|
||||
throw acp.RequestError.invalidParams('modelId cannot be empty');
|
||||
}
|
||||
|
||||
// Attempt to set the model using config
|
||||
await this.config.setModel(modelId, {
|
||||
reason: 'user_request_acp',
|
||||
context: 'session/set_model',
|
||||
});
|
||||
|
||||
// Get updated model info
|
||||
const currentModel = this.config.getModel();
|
||||
|
||||
return {
|
||||
modelId: currentModel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a current_mode_update notification to the client.
|
||||
* Called after the agent switches modes (e.g., from exit_plan_mode tool).
|
||||
|
||||
@@ -20,6 +20,25 @@ import { ExtensionStorage, type Extension } from './extension.js';
|
||||
import * as ServerConfig from '@qwen-code/qwen-code-core';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
import { NativeLspService } from '../services/lsp/NativeLspService.js';
|
||||
|
||||
const createNativeLspServiceInstance = () => ({
|
||||
discoverAndPrepare: vi.fn(),
|
||||
start: vi.fn(),
|
||||
definitions: vi.fn().mockResolvedValue([]),
|
||||
references: vi.fn().mockResolvedValue([]),
|
||||
workspaceSymbols: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
vi.mock('../services/lsp/NativeLspService.js', () => ({
|
||||
NativeLspService: vi.fn().mockImplementation(() => ({
|
||||
discoverAndPrepare: vi.fn(),
|
||||
start: vi.fn(),
|
||||
definitions: vi.fn().mockResolvedValue([]),
|
||||
references: vi.fn().mockResolvedValue([]),
|
||||
workspaceSymbols: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi
|
||||
@@ -27,6 +46,17 @@ vi.mock('./trustedFolders.js', () => ({
|
||||
.mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted
|
||||
}));
|
||||
|
||||
const nativeLspServiceMock = vi.mocked(NativeLspService);
|
||||
const getLastLspInstance = () => {
|
||||
const results = nativeLspServiceMock.mock.results;
|
||||
if (results.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return results[results.length - 1]?.value as ReturnType<
|
||||
typeof createNativeLspServiceInstance
|
||||
>;
|
||||
};
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actualFs = await importOriginal<typeof import('fs')>();
|
||||
const pathMod = await import('node:path');
|
||||
@@ -516,6 +546,10 @@ describe('loadCliConfig', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
nativeLspServiceMock.mockReset();
|
||||
nativeLspServiceMock.mockImplementation(() =>
|
||||
createNativeLspServiceInstance(),
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
});
|
||||
@@ -553,6 +587,127 @@ describe('loadCliConfig', () => {
|
||||
expect(config.getIncludePartialMessages()).toBe(true);
|
||||
});
|
||||
|
||||
it('should set showMemoryUsage to true when --show-memory-usage flag is present', async () => {
|
||||
process.argv = ['node', 'script.js', '--show-memory-usage'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
[],
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
argv,
|
||||
);
|
||||
expect(config.getShowMemoryUsage()).toBe(true);
|
||||
});
|
||||
|
||||
it('should set showMemoryUsage to false when --memory flag is not present', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
[],
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
argv,
|
||||
);
|
||||
expect(config.getShowMemoryUsage()).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize native LSP service when enabled', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {
|
||||
lsp: {
|
||||
enabled: true,
|
||||
allowed: ['typescript-language-server'],
|
||||
excluded: ['pylsp'],
|
||||
},
|
||||
};
|
||||
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
[],
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.isLspEnabled()).toBe(true);
|
||||
expect(config.getLspAllowed()).toEqual(['typescript-language-server']);
|
||||
expect(config.getLspExcluded()).toEqual(['pylsp']);
|
||||
expect(nativeLspServiceMock).toHaveBeenCalledTimes(1);
|
||||
const lspInstance = getLastLspInstance();
|
||||
expect(lspInstance).toBeDefined();
|
||||
expect(lspInstance?.discoverAndPrepare).toHaveBeenCalledTimes(1);
|
||||
expect(lspInstance?.start).toHaveBeenCalledTimes(1);
|
||||
|
||||
const options = nativeLspServiceMock.mock.calls[0][5];
|
||||
expect(options?.allowedServers).toEqual(['typescript-language-server']);
|
||||
expect(options?.excludedServers).toEqual(['pylsp']);
|
||||
});
|
||||
|
||||
it('should skip native LSP startup when startLsp option is false', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = { lsp: { enabled: true } };
|
||||
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
[],
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
argv,
|
||||
undefined,
|
||||
{ startLsp: false },
|
||||
);
|
||||
|
||||
expect(config.isLspEnabled()).toBe(true);
|
||||
expect(nativeLspServiceMock).not.toHaveBeenCalled();
|
||||
expect(getLastLspInstance()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set showMemoryUsage to false by default from settings if CLI flag is not present', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = { ui: { showMemoryUsage: false } };
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
[],
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
argv,
|
||||
);
|
||||
expect(config.getShowMemoryUsage()).toBe(false);
|
||||
});
|
||||
|
||||
it('should prioritize CLI flag over settings for showMemoryUsage (CLI true, settings false)', async () => {
|
||||
process.argv = ['node', 'script.js', '--show-memory-usage'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = { ui: { showMemoryUsage: false } };
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
[],
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
argv,
|
||||
);
|
||||
expect(config.getShowMemoryUsage()).toBe(true);
|
||||
});
|
||||
|
||||
describe('Proxy configuration', () => {
|
||||
const originalProxyEnv: { [key: string]: string | undefined } = {};
|
||||
const proxyEnvVars = [
|
||||
@@ -1132,6 +1287,11 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
],
|
||||
true,
|
||||
'tree',
|
||||
{
|
||||
respectGitIgnore: false,
|
||||
respectQwenIgnore: true,
|
||||
},
|
||||
undefined, // maxDirs
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AuthType,
|
||||
Config,
|
||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
FileDiscoveryService,
|
||||
getCurrentGeminiMdFilename,
|
||||
loadServerHierarchicalMemory,
|
||||
@@ -20,8 +21,11 @@ import {
|
||||
OutputFormat,
|
||||
isToolEnabled,
|
||||
SessionService,
|
||||
ideContextStore,
|
||||
type ResumedSessionData,
|
||||
type FileFilteringOptions,
|
||||
type MCPServerConfig,
|
||||
type LspClient,
|
||||
type ToolName,
|
||||
EditTool,
|
||||
ShellTool,
|
||||
@@ -46,6 +50,7 @@ import { annotateActiveExtensions } from './extension.js';
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
import { appEvents } from '../utils/events.js';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
import { NativeLspService } from '../services/lsp/NativeLspService.js';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
@@ -103,6 +108,7 @@ export interface CliArgs {
|
||||
prompt: string | undefined;
|
||||
promptInteractive: string | undefined;
|
||||
allFiles: boolean | undefined;
|
||||
showMemoryUsage: boolean | undefined;
|
||||
yolo: boolean | undefined;
|
||||
approvalMode: string | undefined;
|
||||
telemetry: boolean | undefined;
|
||||
@@ -151,6 +157,105 @@ export interface CliArgs {
|
||||
channel: string | undefined;
|
||||
}
|
||||
|
||||
export interface LoadCliConfigOptions {
|
||||
/**
|
||||
* Whether to start the native LSP service during config load.
|
||||
* Disable when doing preflight runs (e.g., sandbox preparation).
|
||||
*/
|
||||
startLsp?: boolean;
|
||||
}
|
||||
|
||||
class NativeLspClient implements LspClient {
|
||||
constructor(private readonly service: NativeLspService) {}
|
||||
|
||||
workspaceSymbols(query: string, limit?: number) {
|
||||
return this.service.workspaceSymbols(query, limit);
|
||||
}
|
||||
|
||||
definitions(
|
||||
location: Parameters<NativeLspService['definitions']>[0],
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
) {
|
||||
return this.service.definitions(location, serverName, limit);
|
||||
}
|
||||
|
||||
references(
|
||||
location: Parameters<NativeLspService['references']>[0],
|
||||
serverName?: string,
|
||||
includeDeclaration?: boolean,
|
||||
limit?: number,
|
||||
) {
|
||||
return this.service.references(
|
||||
location,
|
||||
serverName,
|
||||
includeDeclaration,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hover information (documentation, type info) for a symbol.
|
||||
*/
|
||||
hover(
|
||||
location: Parameters<NativeLspService['hover']>[0],
|
||||
serverName?: string,
|
||||
) {
|
||||
return this.service.hover(location, serverName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all symbols in a document.
|
||||
*/
|
||||
documentSymbols(uri: string, serverName?: string, limit?: number) {
|
||||
return this.service.documentSymbols(uri, serverName, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find implementations of an interface or abstract method.
|
||||
*/
|
||||
implementations(
|
||||
location: Parameters<NativeLspService['implementations']>[0],
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
) {
|
||||
return this.service.implementations(location, serverName, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare call hierarchy item at a position (functions/methods).
|
||||
*/
|
||||
prepareCallHierarchy(
|
||||
location: Parameters<NativeLspService['prepareCallHierarchy']>[0],
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
) {
|
||||
return this.service.prepareCallHierarchy(location, serverName, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all functions/methods that call the given function.
|
||||
*/
|
||||
incomingCalls(
|
||||
item: Parameters<NativeLspService['incomingCalls']>[0],
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
) {
|
||||
return this.service.incomingCalls(item, serverName, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all functions/methods called by the given function.
|
||||
*/
|
||||
outgoingCalls(
|
||||
item: Parameters<NativeLspService['outgoingCalls']>[0],
|
||||
serverName?: string,
|
||||
limit?: number,
|
||||
) {
|
||||
return this.service.outgoingCalls(item, serverName, limit);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(
|
||||
format: string | OutputFormat | undefined,
|
||||
): OutputFormat | undefined {
|
||||
@@ -295,6 +400,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
description: 'Include ALL files in context?',
|
||||
default: false,
|
||||
})
|
||||
.option('show-memory-usage', {
|
||||
type: 'boolean',
|
||||
description: 'Show memory usage in status bar',
|
||||
default: false,
|
||||
})
|
||||
.option('yolo', {
|
||||
alias: 'y',
|
||||
type: 'boolean',
|
||||
@@ -326,14 +436,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
.option('experimental-skills', {
|
||||
type: 'boolean',
|
||||
description: 'Enable experimental Skills feature',
|
||||
default: (() => {
|
||||
const legacySkills = (
|
||||
settings as Settings & {
|
||||
tools?: { experimental?: { skills?: boolean } };
|
||||
}
|
||||
).tools?.experimental?.skills;
|
||||
return settings.experimental?.skills ?? legacySkills ?? false;
|
||||
})(),
|
||||
default: settings.tools?.experimental?.skills ?? false,
|
||||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
@@ -497,6 +600,10 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
],
|
||||
description: 'Authentication type',
|
||||
})
|
||||
.deprecateOption(
|
||||
'show-memory-usage',
|
||||
'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'sandbox-image',
|
||||
'Use the "tools.sandbox" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
@@ -638,6 +745,7 @@ export async function loadHierarchicalGeminiMemory(
|
||||
extensionContextFilePaths: string[] = [],
|
||||
folderTrust: boolean,
|
||||
memoryImportFormat: 'flat' | 'tree' = 'tree',
|
||||
fileFilteringOptions?: FileFilteringOptions,
|
||||
): Promise<{ memoryContent: string; fileCount: number }> {
|
||||
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
|
||||
const realCwd = fs.realpathSync(path.resolve(currentWorkingDirectory));
|
||||
@@ -663,6 +771,8 @@ export async function loadHierarchicalGeminiMemory(
|
||||
extensionContextFilePaths,
|
||||
folderTrust,
|
||||
memoryImportFormat,
|
||||
fileFilteringOptions,
|
||||
settings.context?.discoveryMaxDirs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -681,6 +791,7 @@ export async function loadCliConfig(
|
||||
extensionEnablementManager: ExtensionEnablementManager,
|
||||
argv: CliArgs,
|
||||
cwd: string = process.cwd(),
|
||||
options: LoadCliConfigOptions = {},
|
||||
): Promise<Config> {
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
@@ -732,6 +843,11 @@ export async function loadCliConfig(
|
||||
|
||||
const fileService = new FileDiscoveryService(cwd);
|
||||
|
||||
const fileFiltering = {
|
||||
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
...settings.context?.fileFiltering,
|
||||
};
|
||||
|
||||
const includeDirectories = (settings.context?.includeDirectories || [])
|
||||
.map(resolvePath)
|
||||
.concat((argv.includeDirectories || []).map(resolvePath));
|
||||
@@ -748,9 +864,17 @@ export async function loadCliConfig(
|
||||
extensionContextFilePaths,
|
||||
trustedFolder,
|
||||
memoryImportFormat,
|
||||
fileFiltering,
|
||||
);
|
||||
|
||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||
|
||||
// LSP configuration derived from settings; defaults to disabled for safety.
|
||||
const lspEnabled = settings.lsp?.enabled ?? false;
|
||||
const lspAllowed = settings.lsp?.allowed ?? settings.mcp?.allowed;
|
||||
const lspExcluded = settings.lsp?.excluded ?? settings.mcp?.excluded;
|
||||
const lspLanguageServers = settings.lsp?.languageServers;
|
||||
let lspClient: LspClient | undefined;
|
||||
const question = argv.promptInteractive || argv.prompt || '';
|
||||
const inputFormat: InputFormat =
|
||||
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
|
||||
@@ -977,7 +1101,7 @@ export async function loadCliConfig(
|
||||
|
||||
const modelProvidersConfig = settings.modelProviders;
|
||||
|
||||
return new Config({
|
||||
const config = new Config({
|
||||
sessionId,
|
||||
sessionData,
|
||||
embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
@@ -999,6 +1123,8 @@ export async function loadCliConfig(
|
||||
userMemory: memoryContent,
|
||||
geminiMdFileCount: fileCount,
|
||||
approvalMode,
|
||||
showMemoryUsage:
|
||||
argv.showMemoryUsage || settings.ui?.showMemoryUsage || false,
|
||||
accessibility: {
|
||||
...settings.ui?.accessibility,
|
||||
screenReader,
|
||||
@@ -1065,7 +1191,40 @@ export async function loadCliConfig(
|
||||
// always be true and the settings file can never disable recording.
|
||||
chatRecording:
|
||||
argv.chatRecording ?? settings.general?.chatRecording ?? true,
|
||||
lsp: {
|
||||
enabled: lspEnabled,
|
||||
allowed: lspAllowed,
|
||||
excluded: lspExcluded,
|
||||
},
|
||||
});
|
||||
|
||||
const shouldStartLsp = options.startLsp ?? true;
|
||||
if (shouldStartLsp && lspEnabled) {
|
||||
try {
|
||||
const lspService = new NativeLspService(
|
||||
config,
|
||||
config.getWorkspaceContext(),
|
||||
appEvents,
|
||||
fileService,
|
||||
ideContextStore,
|
||||
{
|
||||
allowedServers: lspAllowed,
|
||||
excludedServers: lspExcluded,
|
||||
requireTrustedWorkspace: folderTrust,
|
||||
inlineServerConfigs: lspLanguageServers,
|
||||
},
|
||||
);
|
||||
|
||||
await lspService.discoverAndPrepare();
|
||||
await lspService.start();
|
||||
lspClient = new NativeLspClient(lspService);
|
||||
config.setLspClient(lspClient);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to initialize native LSP service:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function allowedMcpServers(
|
||||
|
||||
@@ -122,10 +122,9 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
|
||||
// Auto-completion
|
||||
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
|
||||
// Completion navigation uses only arrow keys
|
||||
// Ctrl+P/N are reserved for history navigation (HISTORY_UP/DOWN)
|
||||
[Command.COMPLETION_UP]: [{ key: 'up' }],
|
||||
[Command.COMPLETION_DOWN]: [{ key: 'down' }],
|
||||
// Completion navigation (arrow or Ctrl+P/N)
|
||||
[Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }],
|
||||
[Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }],
|
||||
|
||||
// Text input
|
||||
// Must also exclude shift to allow shift+enter for newline
|
||||
|
||||
38
packages/cli/src/config/lspSettingsSchema.ts
Normal file
38
packages/cli/src/config/lspSettingsSchema.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
|
||||
export const lspSettingsSchema: JSONSchema7 = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
'lsp.enabled': {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: '启用 LSP 语言服务器协议支持'
|
||||
},
|
||||
'lsp.allowed': {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
default: [],
|
||||
description: '允许运行的 LSP 服务器列表'
|
||||
},
|
||||
'lsp.excluded': {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
default: [],
|
||||
description: '禁止运行的 LSP 服务器列表'
|
||||
},
|
||||
'lsp.autoDetect': {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: '自动检测项目语言并启动相应 LSP 服务器'
|
||||
},
|
||||
'lsp.serverTimeout': {
|
||||
type: 'number',
|
||||
default: 10000,
|
||||
description: 'LSP 服务器启动超时时间(毫秒)'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2260,7 +2260,7 @@ describe('Settings Loading and Merging', () => {
|
||||
disableAutoUpdate: true,
|
||||
},
|
||||
ui: {
|
||||
hideTips: true,
|
||||
hideBanner: true,
|
||||
customThemes: {
|
||||
myTheme: {},
|
||||
},
|
||||
@@ -2283,7 +2283,7 @@ describe('Settings Loading and Merging', () => {
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
disableAutoUpdate: true,
|
||||
hideTips: true,
|
||||
hideBanner: true,
|
||||
customThemes: {
|
||||
myTheme: {},
|
||||
},
|
||||
|
||||
@@ -90,6 +90,13 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
hideWindowTitle: 'ui.hideWindowTitle',
|
||||
showStatusInTitle: 'ui.showStatusInTitle',
|
||||
hideTips: 'ui.hideTips',
|
||||
hideBanner: 'ui.hideBanner',
|
||||
hideFooter: 'ui.hideFooter',
|
||||
hideCWD: 'ui.footer.hideCWD',
|
||||
hideSandboxStatus: 'ui.footer.hideSandboxStatus',
|
||||
hideModelInfo: 'ui.footer.hideModelInfo',
|
||||
hideContextSummary: 'ui.hideContextSummary',
|
||||
showMemoryUsage: 'ui.showMemoryUsage',
|
||||
showLineNumbers: 'ui.showLineNumbers',
|
||||
showCitations: 'ui.showCitations',
|
||||
ideMode: 'ide.enabled',
|
||||
@@ -99,6 +106,7 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
mcpServers: 'mcpServers',
|
||||
mcpServerCommand: 'mcp.serverCommand',
|
||||
memoryImportFormat: 'context.importFormat',
|
||||
memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
|
||||
model: 'model.name',
|
||||
preferredEditor: 'general.preferredEditor',
|
||||
sandbox: 'tools.sandbox',
|
||||
@@ -152,6 +160,39 @@ export function getSystemDefaultsPath(): string {
|
||||
);
|
||||
}
|
||||
|
||||
function getVsCodeSettingsPath(workspaceDir: string): string {
|
||||
return path.join(workspaceDir, '.vscode', 'settings.json');
|
||||
}
|
||||
|
||||
function loadVsCodeSettings(workspaceDir: string): Settings {
|
||||
const vscodeSettingsPath = getVsCodeSettingsPath(workspaceDir);
|
||||
try {
|
||||
if (fs.existsSync(vscodeSettingsPath)) {
|
||||
const content = fs.readFileSync(vscodeSettingsPath, 'utf-8');
|
||||
const rawSettings: unknown = JSON.parse(stripJsonComments(content));
|
||||
|
||||
if (
|
||||
typeof rawSettings !== 'object' ||
|
||||
rawSettings === null ||
|
||||
Array.isArray(rawSettings)
|
||||
) {
|
||||
console.error(
|
||||
`VS Code settings file is not a valid JSON object: ${vscodeSettingsPath}`,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
return rawSettings as Settings;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`Error loading VS Code settings from ${vscodeSettingsPath}:`,
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export type { DnsResolutionOrder } from './settingsSchema.js';
|
||||
|
||||
export enum SettingScope {
|
||||
@@ -715,6 +756,9 @@ export function loadSettings(
|
||||
workspaceDir,
|
||||
).getWorkspaceSettingsPath();
|
||||
|
||||
// Load VS Code settings as an additional source of configuration
|
||||
const vscodeSettings = loadVsCodeSettings(workspaceDir);
|
||||
|
||||
const loadAndMigrate = (
|
||||
filePath: string,
|
||||
scope: SettingScope,
|
||||
@@ -819,6 +863,14 @@ export function loadSettings(
|
||||
userSettings = resolveEnvVarsInObject(userResult.settings);
|
||||
workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings);
|
||||
|
||||
// Merge VS Code settings into workspace settings (VS Code settings take precedence)
|
||||
workspaceSettings = customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
{},
|
||||
workspaceSettings,
|
||||
vscodeSettings,
|
||||
) as Settings;
|
||||
|
||||
// Support legacy theme names
|
||||
if (userSettings.ui?.theme === 'VS') {
|
||||
userSettings.ui.theme = DefaultLight.name;
|
||||
@@ -832,11 +884,13 @@ export function loadSettings(
|
||||
}
|
||||
|
||||
// For the initial trust check, we can only use user and system settings.
|
||||
// We also include VS Code settings as they may contain trust-related settings
|
||||
const initialTrustCheckSettings = customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
{},
|
||||
systemSettings,
|
||||
userSettings,
|
||||
vscodeSettings, // Include VS Code settings
|
||||
);
|
||||
const isTrusted =
|
||||
isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true;
|
||||
@@ -850,9 +904,18 @@ export function loadSettings(
|
||||
isTrusted,
|
||||
);
|
||||
|
||||
// Add VS Code settings to the temp merged settings for environment loading
|
||||
// Since loadEnvironment depends on settings, we need to consider VS Code settings as well
|
||||
const tempMergedSettingsWithVsCode = customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
{},
|
||||
tempMergedSettings,
|
||||
vscodeSettings,
|
||||
) as Settings;
|
||||
|
||||
// loadEnviroment depends on settings so we have to create a temp version of
|
||||
// the settings to avoid a cycle
|
||||
loadEnvironment(tempMergedSettings);
|
||||
loadEnvironment(tempMergedSettingsWithVsCode);
|
||||
|
||||
// Create LoadedSettings first
|
||||
|
||||
@@ -914,21 +977,6 @@ export function migrateDeprecatedSettings(
|
||||
|
||||
loadedSettings.setValue(scope, 'extensions', newExtensionsValue);
|
||||
}
|
||||
|
||||
const legacySkills = (
|
||||
settings as Settings & {
|
||||
tools?: { experimental?: { skills?: boolean } };
|
||||
}
|
||||
).tools?.experimental?.skills;
|
||||
if (
|
||||
legacySkills !== undefined &&
|
||||
settings.experimental?.skills === undefined
|
||||
) {
|
||||
console.log(
|
||||
`Migrating deprecated tools.experimental.skills setting from ${scope} settings...`,
|
||||
);
|
||||
loadedSettings.setValue(scope, 'experimental.skills', legacySkills);
|
||||
}
|
||||
};
|
||||
|
||||
processScope(SettingScope.User);
|
||||
|
||||
@@ -157,6 +157,9 @@ describe('SettingsSchema', () => {
|
||||
|
||||
it('should have showInDialog property configured', () => {
|
||||
// Check that user-facing settings are marked for dialog display
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showMemoryUsage.showInDialog,
|
||||
).toBe(true);
|
||||
expect(getSettingsSchema().general.properties.vimMode.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -168,14 +171,17 @@ describe('SettingsSchema', () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.hideWindowTitle.showInDialog,
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
expect(getSettingsSchema().ui.properties.hideTips.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(getSettingsSchema().ui.properties.hideBanner.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().privacy.properties.usageStatisticsEnabled
|
||||
.showInDialog,
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
|
||||
// Check that advanced settings are hidden from dialog
|
||||
expect(getSettingsSchema().security.properties.auth.showInDialog).toBe(
|
||||
@@ -188,7 +194,7 @@ describe('SettingsSchema', () => {
|
||||
expect(getSettingsSchema().telemetry.showInDialog).toBe(false);
|
||||
|
||||
// Check that some settings are appropriately hidden
|
||||
expect(getSettingsSchema().ui.properties.theme.showInDialog).toBe(true);
|
||||
expect(getSettingsSchema().ui.properties.theme.showInDialog).toBe(false); // Changed to false
|
||||
expect(getSettingsSchema().ui.properties.customThemes.showInDialog).toBe(
|
||||
false,
|
||||
); // Managed via theme editor
|
||||
@@ -197,13 +203,13 @@ describe('SettingsSchema', () => {
|
||||
).toBe(false); // Experimental feature
|
||||
expect(getSettingsSchema().ui.properties.accessibility.showInDialog).toBe(
|
||||
false,
|
||||
);
|
||||
); // Changed to false
|
||||
expect(
|
||||
getSettingsSchema().context.properties.fileFiltering.showInDialog,
|
||||
).toBe(false);
|
||||
).toBe(false); // Changed to false
|
||||
expect(
|
||||
getSettingsSchema().general.properties.preferredEditor.showInDialog,
|
||||
).toBe(true);
|
||||
).toBe(false); // Changed to false
|
||||
expect(
|
||||
getSettingsSchema().advanced.properties.autoConfigureMemory
|
||||
.showInDialog,
|
||||
@@ -281,7 +287,7 @@ describe('SettingsSchema', () => {
|
||||
expect(
|
||||
getSettingsSchema().security.properties.folderTrust.properties.enabled
|
||||
.showInDialog,
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should have debugKeystrokeLogging setting in schema', () => {
|
||||
@@ -304,7 +310,7 @@ describe('SettingsSchema', () => {
|
||||
expect(
|
||||
getSettingsSchema().general.properties.debugKeystrokeLogging
|
||||
.showInDialog,
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.debugKeystrokeLogging
|
||||
.description,
|
||||
|
||||
@@ -132,7 +132,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description: 'The preferred editor to open files in.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
vimMode: {
|
||||
type: 'boolean',
|
||||
@@ -163,13 +163,13 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
gitCoAuthor: {
|
||||
type: 'boolean',
|
||||
label: 'Attribution: commit',
|
||||
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: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
checkpointing: {
|
||||
type: 'object',
|
||||
@@ -198,13 +198,13 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Enable debug logging of keystrokes to the console.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
language: {
|
||||
type: 'enum',
|
||||
label: 'Language: UI',
|
||||
label: 'Language',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
requiresRestart: false,
|
||||
default: 'auto',
|
||||
description:
|
||||
'The language for the user interface. Use "auto" to detect from system settings. ' +
|
||||
@@ -219,20 +219,9 @@ const SETTINGS_SCHEMA = {
|
||||
{ value: 'de', label: 'Deutsch (German)' },
|
||||
],
|
||||
},
|
||||
outputLanguage: {
|
||||
type: 'string',
|
||||
label: 'Language: Model',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: 'auto',
|
||||
description:
|
||||
'The language for LLM output. Use "auto" to detect from system settings, ' +
|
||||
'or set a specific language (e.g., "English", "中文", "日本語").',
|
||||
showInDialog: true,
|
||||
},
|
||||
terminalBell: {
|
||||
type: 'boolean',
|
||||
label: 'Terminal Bell Notification',
|
||||
label: 'Terminal Bell',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
@@ -268,7 +257,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: 'text',
|
||||
description: 'The format of the CLI output.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
@@ -291,9 +280,9 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Theme',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: 'Qwen Dark' as string,
|
||||
default: undefined as string | undefined,
|
||||
description: 'The color theme for the UI.',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
customThemes: {
|
||||
type: 'object',
|
||||
@@ -311,7 +300,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Hide the window title bar',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
showStatusInTitle: {
|
||||
type: 'boolean',
|
||||
@@ -321,7 +310,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: false,
|
||||
description:
|
||||
'Show Qwen Code status and thoughts in the terminal window title',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
hideTips: {
|
||||
type: 'boolean',
|
||||
@@ -332,13 +321,89 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide helpful tips in the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
showLineNumbers: {
|
||||
hideBanner: {
|
||||
type: 'boolean',
|
||||
label: 'Show Line Numbers in Code',
|
||||
label: 'Hide Banner',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Show line numbers in the code output.',
|
||||
description: 'Hide the application banner',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideContextSummary: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Context Summary',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Hide the context summary (QWEN.md, MCP servers) above the input.',
|
||||
showInDialog: true,
|
||||
},
|
||||
footer: {
|
||||
type: 'object',
|
||||
label: 'Footer',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Settings for the footer.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
hideCWD: {
|
||||
type: 'boolean',
|
||||
label: 'Hide CWD',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Hide the current working directory path in the footer.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideSandboxStatus: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Sandbox Status',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Hide the sandbox status indicator in the footer.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideModelInfo: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Model Info',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Hide the model name and context usage in the footer.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
hideFooter: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Footer',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Hide the footer from the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
showMemoryUsage: {
|
||||
type: 'boolean',
|
||||
label: 'Show Memory Usage',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Display memory usage information in the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
showLineNumbers: {
|
||||
type: 'boolean',
|
||||
label: 'Show Line Numbers',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Show line numbers in the chat.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showCitations: {
|
||||
@@ -348,7 +413,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Show citations for generated text in the chat.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
customWittyPhrases: {
|
||||
type: 'array',
|
||||
@@ -361,7 +426,7 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
enableWelcomeBack: {
|
||||
type: 'boolean',
|
||||
label: 'Show Welcome Back Dialog',
|
||||
label: 'Enable Welcome Back',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
@@ -369,16 +434,6 @@ const SETTINGS_SCHEMA = {
|
||||
'Show welcome back dialog when returning to a project with conversation history.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableUserFeedback: {
|
||||
type: 'boolean',
|
||||
label: 'Enable User Feedback',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Show optional feedback dialog after conversations to help improve Qwen performance.',
|
||||
showInDialog: true,
|
||||
},
|
||||
accessibility: {
|
||||
type: 'object',
|
||||
label: 'Accessibility',
|
||||
@@ -395,7 +450,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Disable loading phrases for accessibility',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
screenReader: {
|
||||
type: 'boolean',
|
||||
@@ -405,19 +460,10 @@ const SETTINGS_SCHEMA = {
|
||||
default: undefined as boolean | undefined,
|
||||
description:
|
||||
'Render output in plain-text to be more screen reader accessible',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
feedbackLastShownTimestamp: {
|
||||
type: 'number',
|
||||
label: 'Feedback Last Shown Timestamp',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: 0,
|
||||
description: 'The last time the feedback dialog was shown.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -432,7 +478,7 @@ const SETTINGS_SCHEMA = {
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Auto-connect to IDE',
|
||||
label: 'IDE Mode',
|
||||
category: 'IDE',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
@@ -467,7 +513,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable collection of usage statistics',
|
||||
showInDialog: true,
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -508,7 +554,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: -1,
|
||||
description:
|
||||
'Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
summarizeToolOutput: {
|
||||
type: 'object',
|
||||
@@ -546,7 +592,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Skip the next speaker check.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
skipLoopDetection: {
|
||||
type: 'boolean',
|
||||
@@ -555,7 +601,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Disable all loop detection checks (streaming and LLM).',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
skipStartupContext: {
|
||||
type: 'boolean',
|
||||
@@ -565,7 +611,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: false,
|
||||
description:
|
||||
'Avoid sending the workspace startup context at the beginning of each session.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
enableOpenAILogging: {
|
||||
type: 'boolean',
|
||||
@@ -574,7 +620,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Enable OpenAI logging.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
openAILoggingDir: {
|
||||
type: 'string',
|
||||
@@ -584,7 +630,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'Custom directory path for OpenAI API logs. If not specified, defaults to logs/openai in the current working directory.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
generationConfig: {
|
||||
type: 'object',
|
||||
@@ -604,7 +650,7 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Request timeout in milliseconds.',
|
||||
parentKey: 'generationConfig',
|
||||
childKey: 'timeout',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
maxRetries: {
|
||||
type: 'number',
|
||||
@@ -615,7 +661,7 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Maximum number of retries for failed requests.',
|
||||
parentKey: 'generationConfig',
|
||||
childKey: 'maxRetries',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
disableCacheControl: {
|
||||
type: 'boolean',
|
||||
@@ -626,7 +672,7 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Disable cache control for DashScope providers.',
|
||||
parentKey: 'generationConfig',
|
||||
childKey: 'disableCacheControl',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
schemaCompliance: {
|
||||
type: 'enum',
|
||||
@@ -638,7 +684,7 @@ const SETTINGS_SCHEMA = {
|
||||
'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: false,
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'auto', label: 'Auto (Default)' },
|
||||
{ value: 'openapi_30', label: 'OpenAPI 3.0 Strict' },
|
||||
@@ -676,6 +722,15 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'The format to use when importing memory.',
|
||||
showInDialog: false,
|
||||
},
|
||||
discoveryMaxDirs: {
|
||||
type: 'number',
|
||||
label: 'Memory Discovery Max Dirs',
|
||||
category: 'Context',
|
||||
requiresRestart: false,
|
||||
default: 200,
|
||||
description: 'Maximum number of directories to search for memory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
includeDirectories: {
|
||||
type: 'array',
|
||||
label: 'Include Directories',
|
||||
@@ -694,7 +749,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Whether to load memory files from include directories.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
fileFiltering: {
|
||||
type: 'object',
|
||||
@@ -730,7 +785,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable recursive file search functionality',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
disableFuzzySearch: {
|
||||
type: 'boolean',
|
||||
@@ -739,7 +794,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Disable fuzzy search when searching for files.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -776,7 +831,7 @@ const SETTINGS_SCHEMA = {
|
||||
properties: {
|
||||
enableInteractiveShell: {
|
||||
type: 'boolean',
|
||||
label: 'Interactive Shell (PTY)',
|
||||
label: 'Enable Interactive Shell',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
@@ -801,10 +856,20 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Show color in shell output.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
autoAccept: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Accept',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).',
|
||||
showInDialog: true,
|
||||
},
|
||||
core: {
|
||||
type: 'array',
|
||||
label: 'Core Tools',
|
||||
@@ -836,7 +901,7 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
approvalMode: {
|
||||
type: 'enum',
|
||||
label: 'Tool Approval Mode',
|
||||
label: 'Approval Mode',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: ApprovalMode.DEFAULT,
|
||||
@@ -850,16 +915,6 @@ const SETTINGS_SCHEMA = {
|
||||
{ value: ApprovalMode.YOLO, label: 'YOLO' },
|
||||
],
|
||||
},
|
||||
autoAccept: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Accept',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Automatically accept and execute tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation.',
|
||||
showInDialog: false,
|
||||
},
|
||||
discoveryCommand: {
|
||||
type: 'string',
|
||||
label: 'Tool Discovery Command',
|
||||
@@ -886,7 +941,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: true,
|
||||
description:
|
||||
'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
useBuiltinRipgrep: {
|
||||
type: 'boolean',
|
||||
@@ -896,7 +951,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: true,
|
||||
description:
|
||||
'Use the bundled ripgrep binary. When set to false, the system-level "rg" command will be used instead. This setting is only effective when useRipgrep is true.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
enableToolOutputTruncation: {
|
||||
type: 'boolean',
|
||||
@@ -905,7 +960,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable truncation of large tool outputs.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
truncateToolOutputThreshold: {
|
||||
type: 'number',
|
||||
@@ -915,7 +970,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
description:
|
||||
'Truncate tool output if it is larger than this many characters. Set to -1 to disable.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
truncateToolOutputLines: {
|
||||
type: 'number',
|
||||
@@ -924,7 +979,28 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
description: 'The number of lines to keep when truncating tool output.',
|
||||
showInDialog: true,
|
||||
},
|
||||
experimental: {
|
||||
type: 'object',
|
||||
label: 'Experimental',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Experimental tool features.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
skills: {
|
||||
type: 'boolean',
|
||||
label: 'Skills',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable experimental Agent Skills feature. When enabled, Qwen Code can use Skills from .qwen/skills/ and ~/.qwen/skills/.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -967,6 +1043,59 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
lsp: {
|
||||
type: 'object',
|
||||
label: 'LSP',
|
||||
category: 'LSP',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Settings for the native Language Server Protocol integration.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable LSP',
|
||||
category: 'LSP',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable the native LSP client to connect to language servers discovered in the workspace.',
|
||||
showInDialog: false,
|
||||
},
|
||||
allowed: {
|
||||
type: 'array',
|
||||
label: 'Allow LSP Servers',
|
||||
category: 'LSP',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Optional allowlist of LSP server names. If set, only matching servers will start.',
|
||||
showInDialog: false,
|
||||
},
|
||||
excluded: {
|
||||
type: 'array',
|
||||
label: 'Exclude LSP Servers',
|
||||
category: 'LSP',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Optional blocklist of LSP server names that should not start.',
|
||||
showInDialog: false,
|
||||
},
|
||||
languageServers: {
|
||||
type: 'object',
|
||||
label: 'LSP Language Servers',
|
||||
category: 'LSP',
|
||||
requiresRestart: true,
|
||||
default: {} as Record<string, unknown>,
|
||||
description:
|
||||
'Inline LSP server configuration (same format as .lsp.json).',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
||||
},
|
||||
},
|
||||
},
|
||||
useSmartEdit: {
|
||||
type: 'boolean',
|
||||
label: 'Use Smart Edit',
|
||||
@@ -1001,7 +1130,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Setting to track whether Folder trust is enabled.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1152,16 +1281,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Setting to enable experimental features',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
skills: {
|
||||
type: 'boolean',
|
||||
label: 'Experimental: Skills',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable experimental Agent Skills feature. When enabled, Qwen Code can use Skills from .qwen/skills/ and ~/.qwen/skills/.',
|
||||
showInDialog: true,
|
||||
},
|
||||
extensionManagement: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Management',
|
||||
@@ -1179,7 +1298,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: true,
|
||||
description:
|
||||
'Enable vision model support and auto-switching functionality. When disabled, vision models like qwen-vl-max-latest will be hidden and auto-switching will not occur.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
vlmSwitchMode: {
|
||||
type: 'string',
|
||||
@@ -1247,3 +1366,9 @@ type InferSettings<T extends SettingsSchema> = {
|
||||
};
|
||||
|
||||
export type Settings = InferSettings<SettingsSchemaType>;
|
||||
|
||||
export interface FooterSettings {
|
||||
hideCWD?: boolean;
|
||||
hideSandboxStatus?: boolean;
|
||||
hideModelInfo?: boolean;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { type LoadedSettings, SettingScope } from '../config/settings.js';
|
||||
import { performInitialAuth } from './auth.js';
|
||||
import { validateTheme } from './theme.js';
|
||||
import { initializeI18n } from '../i18n/index.js';
|
||||
import { initializeLlmOutputLanguage } from '../utils/languageUtils.js';
|
||||
import { initializeLlmOutputLanguage } from '../ui/commands/languageCommand.js';
|
||||
|
||||
export interface InitializationResult {
|
||||
authError: string | null;
|
||||
@@ -43,7 +43,7 @@ export async function initializeApp(
|
||||
await initializeI18n(languageSetting);
|
||||
|
||||
// Auto-detect and set LLM output language on first use
|
||||
initializeLlmOutputLanguage(settings.merged.general?.outputLanguage);
|
||||
initializeLlmOutputLanguage();
|
||||
|
||||
// Use authType from modelsConfig which respects CLI --auth-type argument
|
||||
// over settings.security.auth.selectedType
|
||||
|
||||
@@ -456,6 +456,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
promptInteractive: undefined,
|
||||
query: undefined,
|
||||
allFiles: undefined,
|
||||
showMemoryUsage: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
telemetry: undefined,
|
||||
|
||||
@@ -254,6 +254,8 @@ export async function main() {
|
||||
[],
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
argv,
|
||||
undefined,
|
||||
{ startLsp: false },
|
||||
);
|
||||
|
||||
if (!settings.merged.security?.auth?.useExternal) {
|
||||
|
||||
@@ -60,15 +60,10 @@ export default {
|
||||
'show version info': 'Versionsinformationen anzeigen',
|
||||
'submit a bug report': 'Fehlerbericht einreichen',
|
||||
'About Qwen Code': 'Über Qwen Code',
|
||||
Status: 'Status',
|
||||
|
||||
// ============================================================================
|
||||
// System Information Fields
|
||||
// ============================================================================
|
||||
'Qwen Code': 'Qwen Code',
|
||||
Runtime: 'Laufzeit',
|
||||
OS: 'Betriebssystem',
|
||||
Auth: 'Authentifizierung',
|
||||
'CLI Version': 'CLI-Version',
|
||||
'Git Commit': 'Git-Commit',
|
||||
Model: 'Modell',
|
||||
@@ -81,7 +76,6 @@ export default {
|
||||
'Session ID': 'Sitzungs-ID',
|
||||
'Auth Method': 'Authentifizierungsmethode',
|
||||
'Base URL': 'Basis-URL',
|
||||
Proxy: 'Proxy',
|
||||
'Memory Usage': 'Speichernutzung',
|
||||
'IDE Client': 'IDE-Client',
|
||||
|
||||
@@ -103,8 +97,8 @@ export default {
|
||||
Preview: 'Vorschau',
|
||||
'(Use Enter to select, Tab to configure scope)':
|
||||
'(Enter zum Auswählen, Tab zum Konfigurieren des Bereichs)',
|
||||
'(Use Enter to apply scope, Tab to go back)':
|
||||
'(Enter zum Anwenden des Bereichs, Tab zum Zurückgehen)',
|
||||
'(Use Enter to apply scope, Tab to select theme)':
|
||||
'(Enter zum Anwenden des Bereichs, Tab zum Auswählen des Designs)',
|
||||
'Theme configuration unavailable due to NO_COLOR env variable.':
|
||||
'Design-Konfiguration aufgrund der NO_COLOR-Umgebungsvariable nicht verfügbar.',
|
||||
'Theme "{{themeName}}" not found.': 'Design "{{themeName}}" nicht gefunden.',
|
||||
@@ -266,6 +260,8 @@ export default {
|
||||
'View and edit Qwen Code settings':
|
||||
'Qwen Code Einstellungen anzeigen und bearbeiten',
|
||||
Settings: 'Einstellungen',
|
||||
'(Use Enter to select{{tabText}})': '(Enter zum Auswählen{{tabText}})',
|
||||
', Tab to change focus': ', Tab zum Fokuswechsel',
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||
'Um Änderungen zu sehen, muss Qwen Code neu gestartet werden. Drücken Sie r, um jetzt zu beenden und Änderungen anzuwenden.',
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.':
|
||||
@@ -275,31 +271,24 @@ export default {
|
||||
// ============================================================================
|
||||
'Vim Mode': 'Vim-Modus',
|
||||
'Disable Auto Update': 'Automatische Updates deaktivieren',
|
||||
'Attribution: commit': 'Attribution: Commit',
|
||||
'Terminal Bell Notification': 'Terminal-Signalton',
|
||||
'Enable Usage Statistics': 'Nutzungsstatistiken aktivieren',
|
||||
Theme: 'Farbschema',
|
||||
'Preferred Editor': 'Bevorzugter Editor',
|
||||
'Auto-connect to IDE': 'Automatische Verbindung zur IDE',
|
||||
'Enable Prompt Completion': 'Eingabevervollständigung aktivieren',
|
||||
'Debug Keystroke Logging': 'Debug-Protokollierung von Tastatureingaben',
|
||||
'Language: UI': 'Sprache: Benutzeroberfläche',
|
||||
'Language: Model': 'Sprache: Modell',
|
||||
Language: 'Sprache',
|
||||
'Output Format': 'Ausgabeformat',
|
||||
'Hide Window Title': 'Fenstertitel ausblenden',
|
||||
'Show Status in Title': 'Status im Titel anzeigen',
|
||||
'Hide Tips': 'Tipps ausblenden',
|
||||
'Show Line Numbers in Code': 'Zeilennummern im Code anzeigen',
|
||||
'Hide Banner': 'Banner ausblenden',
|
||||
'Hide Context Summary': 'Kontextzusammenfassung ausblenden',
|
||||
'Hide CWD': 'Arbeitsverzeichnis ausblenden',
|
||||
'Hide Sandbox Status': 'Sandbox-Status ausblenden',
|
||||
'Hide Model Info': 'Modellinformationen ausblenden',
|
||||
'Hide Footer': 'Fußzeile ausblenden',
|
||||
'Show Memory Usage': 'Speichernutzung anzeigen',
|
||||
'Show Line Numbers': 'Zeilennummern anzeigen',
|
||||
'Show Citations': 'Quellenangaben anzeigen',
|
||||
'Custom Witty Phrases': 'Benutzerdefinierte Witzige Sprüche',
|
||||
'Show Welcome Back Dialog': 'Willkommen-zurück-Dialog anzeigen',
|
||||
'Enable User Feedback': 'Benutzerfeedback aktivieren',
|
||||
'How is Qwen doing this session? (optional)':
|
||||
'Wie macht sich Qwen in dieser Sitzung? (optional)',
|
||||
Bad: 'Schlecht',
|
||||
Good: 'Gut',
|
||||
'Not Sure Yet': 'Noch nicht sicher',
|
||||
'Any other key': 'Beliebige andere Taste',
|
||||
'Enable Welcome Back': 'Willkommen-zurück aktivieren',
|
||||
'Disable Loading Phrases': 'Ladesprüche deaktivieren',
|
||||
'Screen Reader Mode': 'Bildschirmleser-Modus',
|
||||
'IDE Mode': 'IDE-Modus',
|
||||
@@ -319,7 +308,7 @@ export default {
|
||||
'Respect .qwenignore': '.qwenignore beachten',
|
||||
'Enable Recursive File Search': 'Rekursive Dateisuche aktivieren',
|
||||
'Disable Fuzzy Search': 'Unscharfe Suche deaktivieren',
|
||||
'Interactive Shell (PTY)': 'Interaktive Shell (PTY)',
|
||||
'Enable Interactive Shell': 'Interaktive Shell aktivieren',
|
||||
'Show Color': 'Farbe anzeigen',
|
||||
'Auto Accept': 'Automatisch akzeptieren',
|
||||
'Use Ripgrep': 'Ripgrep verwenden',
|
||||
@@ -331,7 +320,6 @@ export default {
|
||||
'Folder Trust': 'Ordnervertrauen',
|
||||
'Vision Model Preview': 'Vision-Modell-Vorschau',
|
||||
'Tool Schema Compliance': 'Werkzeug-Schema-Konformität',
|
||||
'Experimental: Skills': 'Experimentell: Fähigkeiten',
|
||||
// Settings enum options
|
||||
'Auto (detect from system)': 'Automatisch (vom System erkennen)',
|
||||
Text: 'Text',
|
||||
@@ -356,11 +344,6 @@ export default {
|
||||
'Show all directories in the workspace':
|
||||
'Alle Verzeichnisse im Arbeitsbereich anzeigen',
|
||||
'set external editor preference': 'Externen Editor festlegen',
|
||||
'Select Editor': 'Editor auswählen',
|
||||
'Editor Preference': 'Editor-Einstellung',
|
||||
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.':
|
||||
'Diese Editoren werden derzeit unterstützt. Bitte beachten Sie, dass einige Editoren nicht im Sandbox-Modus verwendet werden können.',
|
||||
'Your preferred editor is:': 'Ihr bevorzugter Editor ist:',
|
||||
'Manage extensions': 'Erweiterungen verwalten',
|
||||
'List active extensions': 'Aktive Erweiterungen auflisten',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
@@ -429,8 +412,6 @@ export default {
|
||||
'Example: /language output English': 'Beispiel: /language output English',
|
||||
'Example: /language output 日本語': 'Beispiel: /language output Japanisch',
|
||||
'UI language changed to {{lang}}': 'UI-Sprache geändert zu {{lang}}',
|
||||
'LLM output language set to {{lang}}':
|
||||
'LLM-Ausgabesprache auf {{lang}} gesetzt',
|
||||
'LLM output language rule file generated at {{path}}':
|
||||
'LLM-Ausgabesprach-Regeldatei generiert unter {{path}}',
|
||||
'Please restart the application for the changes to take effect.':
|
||||
@@ -453,7 +434,7 @@ export default {
|
||||
// ============================================================================
|
||||
// Commands - Approval Mode
|
||||
// ============================================================================
|
||||
'Tool Approval Mode': 'Werkzeug-Genehmigungsmodus',
|
||||
'Approval Mode': 'Genehmigungsmodus',
|
||||
'Current approval mode: {{mode}}': 'Aktueller Genehmigungsmodus: {{mode}}',
|
||||
'Available approval modes:': 'Verfügbare Genehmigungsmodi:',
|
||||
'Approval mode changed to: {{mode}}':
|
||||
@@ -495,6 +476,8 @@ export default {
|
||||
'Automatically approve all tools': 'Alle Werkzeuge automatisch genehmigen',
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
|
||||
'Arbeitsbereich-Genehmigungsmodus existiert und hat Vorrang. Benutzerebene-Änderung hat keine Wirkung.',
|
||||
'(Use Enter to select, Tab to change focus)':
|
||||
'(Enter zum Auswählen, Tab zum Fokuswechsel)',
|
||||
'Apply To': 'Anwenden auf',
|
||||
'User Settings': 'Benutzereinstellungen',
|
||||
'Workspace Settings': 'Arbeitsbereich-Einstellungen',
|
||||
|
||||
@@ -33,25 +33,6 @@ export default {
|
||||
'Model Context Protocol command (from external servers)':
|
||||
'Model Context Protocol command (from external servers)',
|
||||
'Keyboard Shortcuts:': 'Keyboard Shortcuts:',
|
||||
'Toggle this help display': 'Toggle this help display',
|
||||
'Toggle shell mode': 'Toggle shell mode',
|
||||
'Open command menu': 'Open command menu',
|
||||
'Add file context': 'Add file context',
|
||||
'Accept suggestion / Autocomplete': 'Accept suggestion / Autocomplete',
|
||||
'Reverse search history': 'Reverse search history',
|
||||
'Press ? again to close': 'Press ? again to close',
|
||||
// Keyboard shortcuts panel descriptions
|
||||
'for shell mode': 'for shell mode',
|
||||
'for commands': 'for commands',
|
||||
'for file paths': 'for file paths',
|
||||
'to clear input': 'to clear input',
|
||||
'to cycle approvals': 'to cycle approvals',
|
||||
'to quit': 'to quit',
|
||||
'for newline': 'for newline',
|
||||
'to clear screen': 'to clear screen',
|
||||
'to search history': 'to search history',
|
||||
'to paste images': 'to paste images',
|
||||
'for external editor': 'for external editor',
|
||||
'Jump through words in the input': 'Jump through words in the input',
|
||||
'Close dialogs, cancel requests, or quit application':
|
||||
'Close dialogs, cancel requests, or quit application',
|
||||
@@ -65,7 +46,6 @@ export default {
|
||||
'Connecting to MCP servers... ({{connected}}/{{total}})':
|
||||
'Connecting to MCP servers... ({{connected}}/{{total}})',
|
||||
'Type your message or @path/to/file': 'Type your message or @path/to/file',
|
||||
'? for shortcuts': '? for shortcuts',
|
||||
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.":
|
||||
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.",
|
||||
'Cancel operation / Clear input (double press)':
|
||||
@@ -79,15 +59,10 @@ export default {
|
||||
'show version info': 'show version info',
|
||||
'submit a bug report': 'submit a bug report',
|
||||
'About Qwen Code': 'About Qwen Code',
|
||||
Status: 'Status',
|
||||
|
||||
// ============================================================================
|
||||
// System Information Fields
|
||||
// ============================================================================
|
||||
'Qwen Code': 'Qwen Code',
|
||||
Runtime: 'Runtime',
|
||||
OS: 'OS',
|
||||
Auth: 'Auth',
|
||||
'CLI Version': 'CLI Version',
|
||||
'Git Commit': 'Git Commit',
|
||||
Model: 'Model',
|
||||
@@ -100,7 +75,6 @@ export default {
|
||||
'Session ID': 'Session ID',
|
||||
'Auth Method': 'Auth Method',
|
||||
'Base URL': 'Base URL',
|
||||
Proxy: 'Proxy',
|
||||
'Memory Usage': 'Memory Usage',
|
||||
'IDE Client': 'IDE Client',
|
||||
|
||||
@@ -124,8 +98,8 @@ export default {
|
||||
Preview: 'Preview',
|
||||
'(Use Enter to select, Tab to configure scope)':
|
||||
'(Use Enter to select, Tab to configure scope)',
|
||||
'(Use Enter to apply scope, Tab to go back)':
|
||||
'(Use Enter to apply scope, Tab to go back)',
|
||||
'(Use Enter to apply scope, Tab to select theme)':
|
||||
'(Use Enter to apply scope, Tab to select theme)',
|
||||
'Theme configuration unavailable due to NO_COLOR env variable.':
|
||||
'Theme configuration unavailable due to NO_COLOR env variable.',
|
||||
'Theme "{{themeName}}" not found.': 'Theme "{{themeName}}" not found.',
|
||||
@@ -283,6 +257,8 @@ export default {
|
||||
// ============================================================================
|
||||
'View and edit Qwen Code settings': 'View and edit Qwen Code settings',
|
||||
Settings: 'Settings',
|
||||
'(Use Enter to select{{tabText}})': '(Use Enter to select{{tabText}})',
|
||||
', Tab to change focus': ', Tab to change focus',
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.':
|
||||
@@ -292,31 +268,24 @@ export default {
|
||||
// ============================================================================
|
||||
'Vim Mode': 'Vim Mode',
|
||||
'Disable Auto Update': 'Disable Auto Update',
|
||||
'Attribution: commit': 'Attribution: commit',
|
||||
'Terminal Bell Notification': 'Terminal Bell Notification',
|
||||
'Enable Usage Statistics': 'Enable Usage Statistics',
|
||||
Theme: 'Theme',
|
||||
'Preferred Editor': 'Preferred Editor',
|
||||
'Auto-connect to IDE': 'Auto-connect to IDE',
|
||||
'Enable Prompt Completion': 'Enable Prompt Completion',
|
||||
'Debug Keystroke Logging': 'Debug Keystroke Logging',
|
||||
'Language: UI': 'Language: UI',
|
||||
'Language: Model': 'Language: Model',
|
||||
Language: 'Language',
|
||||
'Output Format': 'Output Format',
|
||||
'Hide Window Title': 'Hide Window Title',
|
||||
'Show Status in Title': 'Show Status in Title',
|
||||
'Hide Tips': 'Hide Tips',
|
||||
'Show Line Numbers in Code': 'Show Line Numbers in Code',
|
||||
'Hide Banner': 'Hide Banner',
|
||||
'Hide Context Summary': 'Hide Context Summary',
|
||||
'Hide CWD': 'Hide CWD',
|
||||
'Hide Sandbox Status': 'Hide Sandbox Status',
|
||||
'Hide Model Info': 'Hide Model Info',
|
||||
'Hide Footer': 'Hide Footer',
|
||||
'Show Memory Usage': 'Show Memory Usage',
|
||||
'Show Line Numbers': 'Show Line Numbers',
|
||||
'Show Citations': 'Show Citations',
|
||||
'Custom Witty Phrases': 'Custom Witty Phrases',
|
||||
'Show Welcome Back Dialog': 'Show Welcome Back Dialog',
|
||||
'Enable User Feedback': 'Enable User Feedback',
|
||||
'How is Qwen doing this session? (optional)':
|
||||
'How is Qwen doing this session? (optional)',
|
||||
Bad: 'Bad',
|
||||
Good: 'Good',
|
||||
'Not Sure Yet': 'Not Sure Yet',
|
||||
'Any other key': 'Any other key',
|
||||
'Enable Welcome Back': 'Enable Welcome Back',
|
||||
'Disable Loading Phrases': 'Disable Loading Phrases',
|
||||
'Screen Reader Mode': 'Screen Reader Mode',
|
||||
'IDE Mode': 'IDE Mode',
|
||||
@@ -336,7 +305,7 @@ export default {
|
||||
'Respect .qwenignore': 'Respect .qwenignore',
|
||||
'Enable Recursive File Search': 'Enable Recursive File Search',
|
||||
'Disable Fuzzy Search': 'Disable Fuzzy Search',
|
||||
'Interactive Shell (PTY)': 'Interactive Shell (PTY)',
|
||||
'Enable Interactive Shell': 'Enable Interactive Shell',
|
||||
'Show Color': 'Show Color',
|
||||
'Auto Accept': 'Auto Accept',
|
||||
'Use Ripgrep': 'Use Ripgrep',
|
||||
@@ -347,7 +316,6 @@ export default {
|
||||
'Folder Trust': 'Folder Trust',
|
||||
'Vision Model Preview': 'Vision Model Preview',
|
||||
'Tool Schema Compliance': 'Tool Schema Compliance',
|
||||
'Experimental: Skills': 'Experimental: Skills',
|
||||
// Settings enum options
|
||||
'Auto (detect from system)': 'Auto (detect from system)',
|
||||
Text: 'Text',
|
||||
@@ -372,11 +340,6 @@ export default {
|
||||
'Show all directories in the workspace':
|
||||
'Show all directories in the workspace',
|
||||
'set external editor preference': 'set external editor preference',
|
||||
'Select Editor': 'Select Editor',
|
||||
'Editor Preference': 'Editor Preference',
|
||||
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.':
|
||||
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.',
|
||||
'Your preferred editor is:': 'Your preferred editor is:',
|
||||
'Manage extensions': 'Manage extensions',
|
||||
'List active extensions': 'List active extensions',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
@@ -443,7 +406,6 @@ export default {
|
||||
'Example: /language output English': 'Example: /language output English',
|
||||
'Example: /language output 日本語': 'Example: /language output 日本語',
|
||||
'UI language changed to {{lang}}': 'UI language changed to {{lang}}',
|
||||
'LLM output language set to {{lang}}': 'LLM output language set to {{lang}}',
|
||||
'LLM output language rule file generated at {{path}}':
|
||||
'LLM output language rule file generated at {{path}}',
|
||||
'Please restart the application for the changes to take effect.':
|
||||
@@ -465,7 +427,7 @@ export default {
|
||||
// ============================================================================
|
||||
// Commands - Approval Mode
|
||||
// ============================================================================
|
||||
'Tool Approval Mode': 'Tool Approval Mode',
|
||||
'Approval Mode': 'Approval Mode',
|
||||
'Current approval mode: {{mode}}': 'Current approval mode: {{mode}}',
|
||||
'Available approval modes:': 'Available approval modes:',
|
||||
'Approval mode changed to: {{mode}}': 'Approval mode changed to: {{mode}}',
|
||||
@@ -504,6 +466,8 @@ export default {
|
||||
'Automatically approve all tools': 'Automatically approve all tools',
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.',
|
||||
'(Use Enter to select, Tab to change focus)':
|
||||
'(Use Enter to select, Tab to change focus)',
|
||||
'Apply To': 'Apply To',
|
||||
'User Settings': 'User Settings',
|
||||
'Workspace Settings': 'Workspace Settings',
|
||||
@@ -927,23 +891,14 @@ export default {
|
||||
// ============================================================================
|
||||
// Startup Tips
|
||||
// ============================================================================
|
||||
'Tips:': 'Tips:',
|
||||
'Use /compress when the conversation gets long to summarize history and free up context.':
|
||||
'Use /compress when the conversation gets long to summarize history and free up context.',
|
||||
'Start a fresh idea with /clear or /new; the previous session stays available in history.':
|
||||
'Start a fresh idea with /clear or /new; the previous session stays available in history.',
|
||||
'Use /bug to submit issues to the maintainers when something goes off.':
|
||||
'Use /bug to submit issues to the maintainers when something goes off.',
|
||||
'Switch auth type quickly with /auth.':
|
||||
'Switch auth type quickly with /auth.',
|
||||
'You can run any shell commands from Qwen Code using ! (e.g. !ls).':
|
||||
'You can run any shell commands from Qwen Code using ! (e.g. !ls).',
|
||||
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.':
|
||||
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.',
|
||||
'You can resume a previous conversation by running qwen --continue or qwen --resume.':
|
||||
'You can resume a previous conversation by running qwen --continue or qwen --resume.',
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
|
||||
'Tips for getting started:': 'Tips for getting started:',
|
||||
'1. Ask questions, edit files, or run commands.':
|
||||
'1. Ask questions, edit files, or run commands.',
|
||||
'2. Be specific for the best results.':
|
||||
'2. Be specific for the best results.',
|
||||
'files to customize your interactions with Qwen Code.':
|
||||
'files to customize your interactions with Qwen Code.',
|
||||
'for more information.': 'for more information.',
|
||||
|
||||
// ============================================================================
|
||||
// Exit Screen / Stats
|
||||
|
||||
@@ -33,13 +33,6 @@ export default {
|
||||
'Model Context Protocol command (from external servers)':
|
||||
'Команда Model Context Protocol (из внешних серверов)',
|
||||
'Keyboard Shortcuts:': 'Горячие клавиши:',
|
||||
'Toggle this help display': 'Показать/скрыть эту справку',
|
||||
'Toggle shell mode': 'Переключить режим оболочки',
|
||||
'Open command menu': 'Открыть меню команд',
|
||||
'Add file context': 'Добавить файл в контекст',
|
||||
'Accept suggestion / Autocomplete': 'Принять подсказку / Автодополнение',
|
||||
'Reverse search history': 'Обратный поиск по истории',
|
||||
'Press ? again to close': 'Нажмите ? ещё раз, чтобы закрыть',
|
||||
'Jump through words in the input': 'Переход по словам во вводе',
|
||||
'Close dialogs, cancel requests, or quit application':
|
||||
'Закрыть диалоги, отменить запросы или выйти из приложения',
|
||||
@@ -53,7 +46,6 @@ export default {
|
||||
'Connecting to MCP servers... ({{connected}}/{{total}})':
|
||||
'Подключение к MCP-серверам... ({{connected}}/{{total}})',
|
||||
'Type your message or @path/to/file': 'Введите сообщение или @путь/к/файлу',
|
||||
'? for shortcuts': '? — горячие клавиши',
|
||||
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.":
|
||||
"Нажмите 'i' для режима ВСТАВКА и 'Esc' для ОБЫЧНОГО режима.",
|
||||
'Cancel operation / Clear input (double press)':
|
||||
@@ -67,28 +59,10 @@ export default {
|
||||
'show version info': 'Просмотр информации о версии',
|
||||
'submit a bug report': 'Отправка отчёта об ошибке',
|
||||
'About Qwen Code': 'Об Qwen Code',
|
||||
Status: 'Статус',
|
||||
|
||||
// Keyboard shortcuts panel descriptions
|
||||
'for shell mode': 'режим оболочки',
|
||||
'for commands': 'меню команд',
|
||||
'for file paths': 'пути к файлам',
|
||||
'to clear input': 'очистить ввод',
|
||||
'to cycle approvals': 'переключить режим',
|
||||
'to quit': 'выход',
|
||||
'for newline': 'новая строка',
|
||||
'to clear screen': 'очистить экран',
|
||||
'to search history': 'поиск в истории',
|
||||
'to paste images': 'вставить изображения',
|
||||
'for external editor': 'внешний редактор',
|
||||
|
||||
// ============================================================================
|
||||
// Поля системной информации
|
||||
// ============================================================================
|
||||
'Qwen Code': 'Qwen Code',
|
||||
Runtime: 'Среда выполнения',
|
||||
OS: 'ОС',
|
||||
Auth: 'Аутентификация',
|
||||
'CLI Version': 'Версия CLI',
|
||||
'Git Commit': 'Git-коммит',
|
||||
Model: 'Модель',
|
||||
@@ -101,7 +75,6 @@ export default {
|
||||
'Session ID': 'ID сессии',
|
||||
'Auth Method': 'Метод авторизации',
|
||||
'Base URL': 'Базовый URL',
|
||||
Proxy: 'Прокси',
|
||||
'Memory Usage': 'Использование памяти',
|
||||
'IDE Client': 'Клиент IDE',
|
||||
|
||||
@@ -127,8 +100,8 @@ export default {
|
||||
Preview: 'Предпросмотр',
|
||||
'(Use Enter to select, Tab to configure scope)':
|
||||
'(Enter для выбора, Tab для настройки области)',
|
||||
'(Use Enter to apply scope, Tab to go back)':
|
||||
'(Enter для применения области, Tab для возврата)',
|
||||
'(Use Enter to apply scope, Tab to select theme)':
|
||||
'(Enter для применения области, Tab для выбора темы)',
|
||||
'Theme configuration unavailable due to NO_COLOR env variable.':
|
||||
'Настройка темы недоступна из-за переменной окружения NO_COLOR.',
|
||||
'Theme "{{themeName}}" not found.': 'Тема "{{themeName}}" не найдена.',
|
||||
@@ -287,6 +260,8 @@ export default {
|
||||
// ============================================================================
|
||||
'View and edit Qwen Code settings': 'Просмотр и изменение настроек Qwen Code',
|
||||
Settings: 'Настройки',
|
||||
'(Use Enter to select{{tabText}})': '(Enter для выбора{{tabText}})',
|
||||
', Tab to change focus': ', Tab для смены фокуса',
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||
'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.',
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.':
|
||||
@@ -296,31 +271,24 @@ export default {
|
||||
// ============================================================================
|
||||
'Vim Mode': 'Режим Vim',
|
||||
'Disable Auto Update': 'Отключить автообновление',
|
||||
'Attribution: commit': 'Атрибуция: коммит',
|
||||
'Terminal Bell Notification': 'Звуковое уведомление терминала',
|
||||
'Enable Usage Statistics': 'Включить сбор статистики использования',
|
||||
Theme: 'Тема',
|
||||
'Preferred Editor': 'Предпочтительный редактор',
|
||||
'Auto-connect to IDE': 'Автоподключение к IDE',
|
||||
'Enable Prompt Completion': 'Включить автодополнение промптов',
|
||||
'Debug Keystroke Logging': 'Логирование нажатий клавиш для отладки',
|
||||
'Language: UI': 'Язык: интерфейс',
|
||||
'Language: Model': 'Язык: модель',
|
||||
Language: 'Язык',
|
||||
'Output Format': 'Формат вывода',
|
||||
'Hide Window Title': 'Скрыть заголовок окна',
|
||||
'Show Status in Title': 'Показывать статус в заголовке',
|
||||
'Hide Tips': 'Скрыть подсказки',
|
||||
'Show Line Numbers in Code': 'Показывать номера строк в коде',
|
||||
'Hide Banner': 'Скрыть баннер',
|
||||
'Hide Context Summary': 'Скрыть сводку контекста',
|
||||
'Hide CWD': 'Скрыть текущую директорию',
|
||||
'Hide Sandbox Status': 'Скрыть статус песочницы',
|
||||
'Hide Model Info': 'Скрыть информацию о модели',
|
||||
'Hide Footer': 'Скрыть нижний колонтитул',
|
||||
'Show Memory Usage': 'Показывать использование памяти',
|
||||
'Show Line Numbers': 'Показывать номера строк',
|
||||
'Show Citations': 'Показывать цитаты',
|
||||
'Custom Witty Phrases': 'Пользовательские остроумные фразы',
|
||||
'Show Welcome Back Dialog': 'Показывать диалог приветствия',
|
||||
'Enable User Feedback': 'Включить отзывы пользователей',
|
||||
'How is Qwen doing this session? (optional)':
|
||||
'Как дела у Qwen в этой сессии? (необязательно)',
|
||||
Bad: 'Плохо',
|
||||
Good: 'Хорошо',
|
||||
'Not Sure Yet': 'Пока не уверен',
|
||||
'Any other key': 'Любая другая клавиша',
|
||||
'Enable Welcome Back': 'Включить приветствие при возврате',
|
||||
'Disable Loading Phrases': 'Отключить фразы при загрузке',
|
||||
'Screen Reader Mode': 'Режим программы чтения с экрана',
|
||||
'IDE Mode': 'Режим IDE',
|
||||
@@ -340,7 +308,7 @@ export default {
|
||||
'Respect .qwenignore': 'Учитывать .qwenignore',
|
||||
'Enable Recursive File Search': 'Включить рекурсивный поиск файлов',
|
||||
'Disable Fuzzy Search': 'Отключить нечеткий поиск',
|
||||
'Interactive Shell (PTY)': 'Интерактивный терминал (PTY)',
|
||||
'Enable Interactive Shell': 'Включить интерактивный терминал',
|
||||
'Show Color': 'Показывать цвета',
|
||||
'Auto Accept': 'Автоподтверждение',
|
||||
'Use Ripgrep': 'Использовать Ripgrep',
|
||||
@@ -351,7 +319,6 @@ export default {
|
||||
'Folder Trust': 'Доверие к папке',
|
||||
'Vision Model Preview': 'Визуальная модель (предпросмотр)',
|
||||
'Tool Schema Compliance': 'Соответствие схеме инструмента',
|
||||
'Experimental: Skills': 'Экспериментальное: Навыки',
|
||||
// Варианты перечислений настроек
|
||||
'Auto (detect from system)': 'Авто (определить из системы)',
|
||||
Text: 'Текст',
|
||||
@@ -378,11 +345,6 @@ export default {
|
||||
'Показать все директории в рабочем пространстве',
|
||||
'set external editor preference':
|
||||
'Установка предпочитаемого внешнего редактора',
|
||||
'Select Editor': 'Выбрать редактор',
|
||||
'Editor Preference': 'Настройка редактора',
|
||||
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.':
|
||||
'В настоящее время поддерживаются следующие редакторы. Обратите внимание, что некоторые редакторы нельзя использовать в режиме песочницы.',
|
||||
'Your preferred editor is:': 'Ваш предпочитаемый редактор:',
|
||||
'Manage extensions': 'Управление расширениями',
|
||||
'List active extensions': 'Показать активные расширения',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
@@ -450,8 +412,6 @@ export default {
|
||||
'Example: /language output English': 'Пример: /language output English',
|
||||
'Example: /language output 日本語': 'Пример: /language output 日本語',
|
||||
'UI language changed to {{lang}}': 'Язык интерфейса изменен на {{lang}}',
|
||||
'LLM output language set to {{lang}}':
|
||||
'Язык вывода LLM установлен на {{lang}}',
|
||||
'LLM output language rule file generated at {{path}}':
|
||||
'Файл правил языка вывода LLM создан в {{path}}',
|
||||
'Please restart the application for the changes to take effect.':
|
||||
@@ -474,7 +434,7 @@ export default {
|
||||
// ============================================================================
|
||||
// Команды - Режим подтверждения
|
||||
// ============================================================================
|
||||
'Tool Approval Mode': 'Режим подтверждения инструментов',
|
||||
'Approval Mode': 'Режим подтверждения',
|
||||
'Current approval mode: {{mode}}': 'Текущий режим подтверждения: {{mode}}',
|
||||
'Available approval modes:': 'Доступные режимы подтверждения:',
|
||||
'Approval mode changed to: {{mode}}':
|
||||
@@ -516,6 +476,8 @@ export default {
|
||||
'Автоматически подтверждать все инструменты',
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
|
||||
'Режим подтверждения рабочего пространства существует и имеет приоритет. Изменение на уровне пользователя не будет иметь эффекта.',
|
||||
'(Use Enter to select, Tab to change focus)':
|
||||
'(Enter для выбора, Tab для смены фокуса)',
|
||||
'Apply To': 'Применить к',
|
||||
'User Settings': 'Настройки пользователя',
|
||||
'Workspace Settings': 'Настройки рабочего пространства',
|
||||
|
||||
@@ -32,25 +32,6 @@ export default {
|
||||
'Model Context Protocol command (from external servers)':
|
||||
'模型上下文协议命令(来自外部服务器)',
|
||||
'Keyboard Shortcuts:': '键盘快捷键:',
|
||||
'Toggle this help display': '切换此帮助显示',
|
||||
'Toggle shell mode': '切换命令行模式',
|
||||
'Open command menu': '打开命令菜单',
|
||||
'Add file context': '添加文件上下文',
|
||||
'Accept suggestion / Autocomplete': '接受建议 / 自动补全',
|
||||
'Reverse search history': '反向搜索历史',
|
||||
'Press ? again to close': '再次按 ? 关闭',
|
||||
// Keyboard shortcuts panel descriptions
|
||||
'for shell mode': '命令行模式',
|
||||
'for commands': '命令菜单',
|
||||
'for file paths': '文件路径',
|
||||
'to clear input': '清空输入',
|
||||
'to cycle approvals': '切换审批模式',
|
||||
'to quit': '退出',
|
||||
'for newline': '换行',
|
||||
'to clear screen': '清屏',
|
||||
'to search history': '搜索历史',
|
||||
'to paste images': '粘贴图片',
|
||||
'for external editor': '外部编辑器',
|
||||
'Jump through words in the input': '在输入中按单词跳转',
|
||||
'Close dialogs, cancel requests, or quit application':
|
||||
'关闭对话框、取消请求或退出应用程序',
|
||||
@@ -64,7 +45,6 @@ export default {
|
||||
'Connecting to MCP servers... ({{connected}}/{{total}})':
|
||||
'正在连接到 MCP 服务器... ({{connected}}/{{total}})',
|
||||
'Type your message or @path/to/file': '输入您的消息或 @ 文件路径',
|
||||
'? for shortcuts': '按 ? 查看快捷键',
|
||||
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.":
|
||||
"按 'i' 进入插入模式,按 'Esc' 进入普通模式",
|
||||
'Cancel operation / Clear input (double press)':
|
||||
@@ -78,15 +58,10 @@ export default {
|
||||
'show version info': '显示版本信息',
|
||||
'submit a bug report': '提交错误报告',
|
||||
'About Qwen Code': '关于 Qwen Code',
|
||||
Status: '状态',
|
||||
|
||||
// ============================================================================
|
||||
// System Information Fields
|
||||
// ============================================================================
|
||||
'Qwen Code': 'Qwen Code',
|
||||
Runtime: '运行环境',
|
||||
OS: '操作系统',
|
||||
Auth: '认证',
|
||||
'CLI Version': 'CLI 版本',
|
||||
'Git Commit': 'Git 提交',
|
||||
Model: '模型',
|
||||
@@ -99,7 +74,6 @@ export default {
|
||||
'Session ID': '会话 ID',
|
||||
'Auth Method': '认证方式',
|
||||
'Base URL': '基础 URL',
|
||||
Proxy: '代理',
|
||||
'Memory Usage': '内存使用',
|
||||
'IDE Client': 'IDE 客户端',
|
||||
|
||||
@@ -123,8 +97,8 @@ export default {
|
||||
Preview: '预览',
|
||||
'(Use Enter to select, Tab to configure scope)':
|
||||
'(使用 Enter 选择,Tab 配置作用域)',
|
||||
'(Use Enter to apply scope, Tab to go back)':
|
||||
'(使用 Enter 应用作用域,Tab 返回)',
|
||||
'(Use Enter to apply scope, Tab to select theme)':
|
||||
'(使用 Enter 应用作用域,Tab 选择主题)',
|
||||
'Theme configuration unavailable due to NO_COLOR env variable.':
|
||||
'由于 NO_COLOR 环境变量,主题配置不可用。',
|
||||
'Theme "{{themeName}}" not found.': '未找到主题 "{{themeName}}"。',
|
||||
@@ -274,6 +248,8 @@ export default {
|
||||
// ============================================================================
|
||||
'View and edit Qwen Code settings': '查看和编辑 Qwen Code 设置',
|
||||
Settings: '设置',
|
||||
'(Use Enter to select{{tabText}})': '(使用 Enter 选择{{tabText}})',
|
||||
', Tab to change focus': ',Tab 切换焦点',
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||
'要查看更改,必须重启 Qwen Code。按 r 退出并立即应用更改。',
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.':
|
||||
@@ -283,30 +259,24 @@ export default {
|
||||
// ============================================================================
|
||||
'Vim Mode': 'Vim 模式',
|
||||
'Disable Auto Update': '禁用自动更新',
|
||||
'Attribution: commit': '署名:提交',
|
||||
'Terminal Bell Notification': '终端响铃通知',
|
||||
'Enable Usage Statistics': '启用使用统计',
|
||||
Theme: '主题',
|
||||
'Preferred Editor': '首选编辑器',
|
||||
'Auto-connect to IDE': '自动连接到 IDE',
|
||||
'Enable Prompt Completion': '启用提示补全',
|
||||
'Debug Keystroke Logging': '调试按键记录',
|
||||
'Language: UI': '语言:界面',
|
||||
'Language: Model': '语言:模型',
|
||||
Language: '语言',
|
||||
'Output Format': '输出格式',
|
||||
'Hide Window Title': '隐藏窗口标题',
|
||||
'Show Status in Title': '在标题中显示状态',
|
||||
'Hide Tips': '隐藏提示',
|
||||
'Show Line Numbers in Code': '在代码中显示行号',
|
||||
'Hide Banner': '隐藏横幅',
|
||||
'Hide Context Summary': '隐藏上下文摘要',
|
||||
'Hide CWD': '隐藏当前工作目录',
|
||||
'Hide Sandbox Status': '隐藏沙箱状态',
|
||||
'Hide Model Info': '隐藏模型信息',
|
||||
'Hide Footer': '隐藏页脚',
|
||||
'Show Memory Usage': '显示内存使用',
|
||||
'Show Line Numbers': '显示行号',
|
||||
'Show Citations': '显示引用',
|
||||
'Custom Witty Phrases': '自定义诙谐短语',
|
||||
'Show Welcome Back Dialog': '显示欢迎回来对话框',
|
||||
'Enable User Feedback': '启用用户反馈',
|
||||
'How is Qwen doing this session? (optional)': 'Qwen 这次表现如何?(可选)',
|
||||
Bad: '不满意',
|
||||
Good: '满意',
|
||||
'Not Sure Yet': '暂不评价',
|
||||
'Any other key': '任意其他键',
|
||||
'Enable Welcome Back': '启用欢迎回来',
|
||||
'Disable Loading Phrases': '禁用加载短语',
|
||||
'Screen Reader Mode': '屏幕阅读器模式',
|
||||
'IDE Mode': 'IDE 模式',
|
||||
@@ -325,7 +295,7 @@ export default {
|
||||
'Respect .qwenignore': '遵守 .qwenignore',
|
||||
'Enable Recursive File Search': '启用递归文件搜索',
|
||||
'Disable Fuzzy Search': '禁用模糊搜索',
|
||||
'Interactive Shell (PTY)': '交互式 Shell (PTY)',
|
||||
'Enable Interactive Shell': '启用交互式 Shell',
|
||||
'Show Color': '显示颜色',
|
||||
'Auto Accept': '自动接受',
|
||||
'Use Ripgrep': '使用 Ripgrep',
|
||||
@@ -336,7 +306,6 @@ export default {
|
||||
'Folder Trust': '文件夹信任',
|
||||
'Vision Model Preview': '视觉模型预览',
|
||||
'Tool Schema Compliance': '工具 Schema 兼容性',
|
||||
'Experimental: Skills': '实验性: 技能',
|
||||
// Settings enum options
|
||||
'Auto (detect from system)': '自动(从系统检测)',
|
||||
Text: '文本',
|
||||
@@ -358,11 +327,6 @@ export default {
|
||||
'将目录添加到工作区。使用逗号分隔多个路径',
|
||||
'Show all directories in the workspace': '显示工作区中的所有目录',
|
||||
'set external editor preference': '设置外部编辑器首选项',
|
||||
'Select Editor': '选择编辑器',
|
||||
'Editor Preference': '编辑器首选项',
|
||||
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.':
|
||||
'当前支持以下编辑器。请注意,某些编辑器无法在沙箱模式下使用。',
|
||||
'Your preferred editor is:': '您的首选编辑器是:',
|
||||
'Manage extensions': '管理扩展',
|
||||
'List active extensions': '列出活动扩展',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
@@ -426,7 +390,6 @@ export default {
|
||||
'Example: /language output English': '示例:/language output English',
|
||||
'Example: /language output 日本語': '示例:/language output 日本語',
|
||||
'UI language changed to {{lang}}': 'UI 语言已更改为 {{lang}}',
|
||||
'LLM output language set to {{lang}}': 'LLM 输出语言已设置为 {{lang}}',
|
||||
'LLM output language rule file generated at {{path}}':
|
||||
'LLM 输出语言规则文件已生成于 {{path}}',
|
||||
'Please restart the application for the changes to take effect.':
|
||||
@@ -447,7 +410,7 @@ export default {
|
||||
// ============================================================================
|
||||
// Commands - Approval Mode
|
||||
// ============================================================================
|
||||
'Tool Approval Mode': '工具审批模式',
|
||||
'Approval Mode': '审批模式',
|
||||
'Current approval mode: {{mode}}': '当前审批模式:{{mode}}',
|
||||
'Available approval modes:': '可用的审批模式:',
|
||||
'Approval mode changed to: {{mode}}': '审批模式已更改为:{{mode}}',
|
||||
@@ -481,6 +444,8 @@ export default {
|
||||
'Automatically approve all tools': '自动批准所有工具',
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
|
||||
'工作区审批模式已存在并具有优先级。用户级别的更改将无效。',
|
||||
'(Use Enter to select, Tab to change focus)':
|
||||
'(使用 Enter 选择,Tab 切换焦点)',
|
||||
'Apply To': '应用于',
|
||||
'User Settings': '用户设置',
|
||||
'Workspace Settings': '工作区设置',
|
||||
@@ -880,22 +845,13 @@ export default {
|
||||
// ============================================================================
|
||||
// Startup Tips
|
||||
// ============================================================================
|
||||
'Tips:': '提示:',
|
||||
'Use /compress when the conversation gets long to summarize history and free up context.':
|
||||
'对话变长时用 /compress,总结历史并释放上下文。',
|
||||
'Start a fresh idea with /clear or /new; the previous session stays available in history.':
|
||||
'用 /clear 或 /new 开启新思路;之前的会话会保留在历史记录中。',
|
||||
'Use /bug to submit issues to the maintainers when something goes off.':
|
||||
'遇到问题时,用 /bug 将问题提交给维护者。',
|
||||
'Switch auth type quickly with /auth.': '用 /auth 快速切换认证方式。',
|
||||
'You can run any shell commands from Qwen Code using ! (e.g. !ls).':
|
||||
'在 Qwen Code 中使用 ! 可运行任意 shell 命令(例如 !ls)。',
|
||||
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.':
|
||||
'输入 / 打开命令弹窗;按 Tab 自动补全斜杠命令和保存的提示词。',
|
||||
'You can resume a previous conversation by running qwen --continue or qwen --resume.':
|
||||
'运行 qwen --continue 或 qwen --resume 可继续之前的会话。',
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
|
||||
'按 Shift+Tab 或输入 /approval-mode 可快速切换权限模式。',
|
||||
'Tips for getting started:': '入门提示:',
|
||||
'1. Ask questions, edit files, or run commands.':
|
||||
'1. 提问、编辑文件或运行命令',
|
||||
'2. Be specific for the best results.': '2. 具体描述以获得最佳结果',
|
||||
'files to customize your interactions with Qwen Code.':
|
||||
'文件以自定义您与 Qwen Code 的交互',
|
||||
'for more information.': '获取更多信息',
|
||||
|
||||
// ============================================================================
|
||||
// Exit Screen / Stats
|
||||
|
||||
@@ -8,8 +8,7 @@ vi.mock('../ui/commands/aboutCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
aboutCommand: {
|
||||
name: 'status',
|
||||
altNames: ['about'],
|
||||
name: 'about',
|
||||
description: 'About the CLI',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
@@ -128,8 +127,8 @@ describe('BuiltinCommandLoader', () => {
|
||||
expect(ideCmd).toBeDefined();
|
||||
|
||||
// Other commands should still be present.
|
||||
const statusCmd = commands.find((c) => c.name === 'status');
|
||||
expect(statusCmd).toBeDefined();
|
||||
const aboutCmd = commands.find((c) => c.name === 'about');
|
||||
expect(aboutCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle a null config gracefully when calling factories', async () => {
|
||||
@@ -144,9 +143,9 @@ describe('BuiltinCommandLoader', () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
|
||||
const statusCmd = commands.find((c) => c.name === 'status');
|
||||
expect(statusCmd).toBeDefined();
|
||||
expect(statusCmd?.kind).toBe(CommandKind.BUILT_IN);
|
||||
const aboutCmd = commands.find((c) => c.name === 'about');
|
||||
expect(aboutCmd).toBeDefined();
|
||||
expect(aboutCmd?.kind).toBe(CommandKind.BUILT_IN);
|
||||
|
||||
const approvalModeCmd = commands.find((c) => c.name === 'approval-mode');
|
||||
expect(approvalModeCmd).toBeDefined();
|
||||
|
||||
391
packages/cli/src/services/lsp/LspConnectionFactory.ts
Normal file
391
packages/cli/src/services/lsp/LspConnectionFactory.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import * as cp from 'node:child_process';
|
||||
import * as net from 'node:net';
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
class JsonRpcConnection {
|
||||
private buffer = '';
|
||||
private nextId = 1;
|
||||
private disposed = false;
|
||||
private pendingRequests = new Map<string | number, PendingRequest>();
|
||||
private notificationHandlers: Array<(notification: JsonRpcMessage) => void> =
|
||||
[];
|
||||
private requestHandlers: Array<
|
||||
(request: JsonRpcMessage) => Promise<unknown>
|
||||
> = [];
|
||||
|
||||
constructor(
|
||||
private readonly writer: (data: string) => void,
|
||||
private readonly disposer?: () => void,
|
||||
) {}
|
||||
|
||||
listen(readable: NodeJS.ReadableStream): void {
|
||||
readable.on('data', (chunk: Buffer) => this.handleData(chunk));
|
||||
readable.on('error', (error) =>
|
||||
this.disposePending(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
send(message: JsonRpcMessage): void {
|
||||
this.writeMessage(message);
|
||||
}
|
||||
|
||||
onNotification(handler: (notification: JsonRpcMessage) => void): void {
|
||||
this.notificationHandlers.push(handler);
|
||||
}
|
||||
|
||||
onRequest(handler: (request: JsonRpcMessage) => Promise<unknown>): void {
|
||||
this.requestHandlers.push(handler);
|
||||
}
|
||||
|
||||
async initialize(params: unknown): Promise<unknown> {
|
||||
return this.sendRequest('initialize', params);
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
try {
|
||||
await this.sendRequest('shutdown', {});
|
||||
} catch (_error) {
|
||||
// Ignore shutdown errors – the server may already be gone.
|
||||
} finally {
|
||||
this.end();
|
||||
}
|
||||
}
|
||||
|
||||
request(method: string, params: unknown): Promise<unknown> {
|
||||
return this.sendRequest(method, params);
|
||||
}
|
||||
|
||||
end(): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
this.disposePending();
|
||||
this.disposer?.();
|
||||
}
|
||||
|
||||
private sendRequest(method: string, params: unknown): Promise<unknown> {
|
||||
if (this.disposed) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
const id = this.nextId++;
|
||||
const payload: JsonRpcMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
};
|
||||
|
||||
const requestPromise = new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`LSP request timeout: ${method}`));
|
||||
}, 15000);
|
||||
|
||||
this.pendingRequests.set(id, { resolve, reject, timer });
|
||||
});
|
||||
|
||||
this.writeMessage(payload);
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
private async handleServerRequest(message: JsonRpcMessage): Promise<void> {
|
||||
const handler = this.requestHandlers[this.requestHandlers.length - 1];
|
||||
if (!handler) {
|
||||
this.writeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `Method not supported: ${message.method}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(message);
|
||||
this.writeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result: result ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
this.writeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: (error as Error).message ?? 'Internal error',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleData(chunk: Buffer): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.buffer += chunk.toString('utf8');
|
||||
|
||||
while (true) {
|
||||
const headerEnd = this.buffer.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const header = this.buffer.slice(0, headerEnd);
|
||||
const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header);
|
||||
if (!lengthMatch) {
|
||||
this.buffer = this.buffer.slice(headerEnd + 4);
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentLength = Number(lengthMatch[1]);
|
||||
const messageStart = headerEnd + 4;
|
||||
const messageEnd = messageStart + contentLength;
|
||||
|
||||
if (this.buffer.length < messageEnd) {
|
||||
break;
|
||||
}
|
||||
|
||||
const body = this.buffer.slice(messageStart, messageEnd);
|
||||
this.buffer = this.buffer.slice(messageEnd);
|
||||
|
||||
try {
|
||||
const message = JSON.parse(body);
|
||||
this.routeMessage(message);
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private routeMessage(message: JsonRpcMessage): void {
|
||||
if (typeof message?.id !== 'undefined' && !message.method) {
|
||||
const pending = this.pendingRequests.get(message.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingRequests.delete(message.id);
|
||||
if (message.error) {
|
||||
pending.reject(
|
||||
new Error(message.error.message || 'LSP request failed'),
|
||||
);
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.method && typeof message.id !== 'undefined') {
|
||||
void this.handleServerRequest(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.method) {
|
||||
for (const handler of this.notificationHandlers) {
|
||||
try {
|
||||
handler(message);
|
||||
} catch {
|
||||
// ignore handler errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private writeMessage(message: JsonRpcMessage): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
const json = JSON.stringify(message);
|
||||
const header = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n`;
|
||||
this.writer(header + json);
|
||||
}
|
||||
|
||||
private disposePending(error?: Error): void {
|
||||
for (const [, pending] of Array.from(this.pendingRequests)) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error ?? new Error('LSP connection closed'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
}
|
||||
|
||||
interface LspConnection {
|
||||
connection: JsonRpcConnection;
|
||||
process?: cp.ChildProcess;
|
||||
socket?: net.Socket;
|
||||
}
|
||||
|
||||
interface SocketConnectionOptions {
|
||||
host?: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface JsonRpcMessage {
|
||||
jsonrpc: string;
|
||||
id?: number | string;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export class LspConnectionFactory {
|
||||
/**
|
||||
* 创建基于 stdio 的 LSP 连接
|
||||
*/
|
||||
static async createStdioConnection(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: cp.SpawnOptions,
|
||||
timeoutMs = 10000,
|
||||
): Promise<LspConnection> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const spawnOptions: cp.SpawnOptions = {
|
||||
stdio: 'pipe',
|
||||
...options,
|
||||
};
|
||||
const processInstance = cp.spawn(command, args, spawnOptions);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error('LSP server spawn timeout'));
|
||||
if (!processInstance.killed) {
|
||||
processInstance.kill();
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
processInstance.once('error', (error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error(`Failed to spawn LSP server: ${error.message}`));
|
||||
});
|
||||
|
||||
processInstance.once('spawn', () => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!processInstance.stdout || !processInstance.stdin) {
|
||||
reject(new Error('LSP server stdio not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = new JsonRpcConnection(
|
||||
(payload) => processInstance.stdin?.write(payload),
|
||||
() => processInstance.stdin?.end(),
|
||||
);
|
||||
|
||||
connection.listen(processInstance.stdout);
|
||||
processInstance.once('exit', () => connection.end());
|
||||
processInstance.once('close', () => connection.end());
|
||||
|
||||
resolve({
|
||||
connection,
|
||||
process: processInstance,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建基于 TCP 的 LSP 连接
|
||||
*/
|
||||
static async createTcpConnection(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 10000,
|
||||
): Promise<LspConnection> {
|
||||
return LspConnectionFactory.createSocketConnection(
|
||||
{ host, port },
|
||||
timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建基于 socket 的 LSP 连接(支持 TCP 或 unix socket)
|
||||
*/
|
||||
static async createSocketConnection(
|
||||
options: SocketConnectionOptions,
|
||||
timeoutMs = 10000,
|
||||
): Promise<LspConnection> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socketOptions = options.path
|
||||
? { path: options.path }
|
||||
: { host: options.host ?? '127.0.0.1', port: options.port };
|
||||
|
||||
if (!('path' in socketOptions) && !socketOptions.port) {
|
||||
reject(new Error('Socket transport requires port or path'));
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = net.createConnection(socketOptions);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error('LSP server connection timeout'));
|
||||
socket.destroy();
|
||||
}, timeoutMs);
|
||||
|
||||
const onError = (error: Error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error(`Failed to connect to LSP server: ${error.message}`));
|
||||
};
|
||||
|
||||
socket.once('error', onError);
|
||||
|
||||
socket.on('connect', () => {
|
||||
clearTimeout(timeoutId);
|
||||
socket.off('error', onError);
|
||||
|
||||
const connection = new JsonRpcConnection(
|
||||
(payload) => socket.write(payload),
|
||||
() => socket.destroy(),
|
||||
);
|
||||
connection.listen(socket);
|
||||
socket.once('close', () => connection.end());
|
||||
socket.once('error', () => connection.end());
|
||||
|
||||
resolve({
|
||||
connection,
|
||||
socket,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 LSP 连接
|
||||
*/
|
||||
static async closeConnection(lspConnection: LspConnection): Promise<void> {
|
||||
if (lspConnection.connection) {
|
||||
try {
|
||||
await lspConnection.connection.shutdown();
|
||||
} catch (e) {
|
||||
console.warn('LSP shutdown failed:', e);
|
||||
} finally {
|
||||
lspConnection.connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
if (lspConnection.process && !lspConnection.process.killed) {
|
||||
lspConnection.process.kill();
|
||||
}
|
||||
|
||||
if (lspConnection.socket && !lspConnection.socket.destroyed) {
|
||||
lspConnection.socket.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
127
packages/cli/src/services/lsp/NativeLspService.test.ts
Normal file
127
packages/cli/src/services/lsp/NativeLspService.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { NativeLspService } from './NativeLspService.js';
|
||||
import { EventEmitter } from 'events';
|
||||
import type {
|
||||
Config as CoreConfig,
|
||||
WorkspaceContext,
|
||||
FileDiscoveryService,
|
||||
IdeContextStore,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
// 模拟依赖项
|
||||
class MockConfig {
|
||||
rootPath = '/test/workspace';
|
||||
|
||||
isTrustedFolder(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
get(_key: string) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getProjectRoot(): string {
|
||||
return this.rootPath;
|
||||
}
|
||||
}
|
||||
|
||||
class MockWorkspaceContext {
|
||||
rootPath = '/test/workspace';
|
||||
|
||||
async fileExists(_path: string): Promise<boolean> {
|
||||
return _path.endsWith('.json') || _path.includes('package.json');
|
||||
}
|
||||
|
||||
async readFile(_path: string): Promise<string> {
|
||||
if (_path.includes('.lsp.json')) {
|
||||
return JSON.stringify({
|
||||
typescript: {
|
||||
command: 'typescript-language-server',
|
||||
args: ['--stdio'],
|
||||
transport: 'stdio',
|
||||
},
|
||||
});
|
||||
}
|
||||
return '{}';
|
||||
}
|
||||
|
||||
resolvePath(_path: string): string {
|
||||
return this.rootPath + '/' + _path;
|
||||
}
|
||||
|
||||
isPathWithinWorkspace(_path: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
getDirectories(): string[] {
|
||||
return [this.rootPath];
|
||||
}
|
||||
}
|
||||
|
||||
class MockFileDiscoveryService {
|
||||
async discoverFiles(_root: string, _options: unknown): Promise<string[]> {
|
||||
// 模拟发现一些文件
|
||||
return [
|
||||
'/test/workspace/src/index.ts',
|
||||
'/test/workspace/src/utils.ts',
|
||||
'/test/workspace/server.py',
|
||||
'/test/workspace/main.go',
|
||||
];
|
||||
}
|
||||
|
||||
shouldIgnoreFile(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class MockIdeContextStore {
|
||||
// 模拟 IDE 上下文存储
|
||||
}
|
||||
|
||||
describe('NativeLspService', () => {
|
||||
let lspService: NativeLspService;
|
||||
let mockConfig: MockConfig;
|
||||
let mockWorkspace: MockWorkspaceContext;
|
||||
let mockFileDiscovery: MockFileDiscoveryService;
|
||||
let mockIdeStore: MockIdeContextStore;
|
||||
let eventEmitter: EventEmitter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = new MockConfig();
|
||||
mockWorkspace = new MockWorkspaceContext();
|
||||
mockFileDiscovery = new MockFileDiscoveryService();
|
||||
mockIdeStore = new MockIdeContextStore();
|
||||
eventEmitter = new EventEmitter();
|
||||
|
||||
lspService = new NativeLspService(
|
||||
mockConfig as unknown as CoreConfig,
|
||||
mockWorkspace as unknown as WorkspaceContext,
|
||||
eventEmitter,
|
||||
mockFileDiscovery as unknown as FileDiscoveryService,
|
||||
mockIdeStore as unknown as IdeContextStore,
|
||||
);
|
||||
});
|
||||
|
||||
test('should initialize correctly', () => {
|
||||
expect(lspService).toBeDefined();
|
||||
});
|
||||
|
||||
test('should detect languages from workspace files', async () => {
|
||||
// 这个测试需要修改,因为我们无法直接访问私有方法
|
||||
await lspService.discoverAndPrepare();
|
||||
const status = lspService.getStatus();
|
||||
|
||||
// 检查服务是否已准备就绪
|
||||
expect(status).toBeDefined();
|
||||
});
|
||||
|
||||
test('should merge built-in presets with user configs', async () => {
|
||||
await lspService.discoverAndPrepare();
|
||||
|
||||
const status = lspService.getStatus();
|
||||
// 检查服务是否已准备就绪
|
||||
expect(status).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// 注意:实际的单元测试需要适当的测试框架配置
|
||||
// 这里只是一个结构示例
|
||||
2357
packages/cli/src/services/lsp/NativeLspService.ts
Normal file
2357
packages/cli/src/services/lsp/NativeLspService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,15 +5,34 @@
|
||||
*/
|
||||
|
||||
import { useIsScreenReaderEnabled } from 'ink';
|
||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
import { lerp } from '../utils/math.js';
|
||||
import { useUIState } from './contexts/UIStateContext.js';
|
||||
import { StreamingContext } from './contexts/StreamingContext.js';
|
||||
import { QuittingDisplay } from './components/QuittingDisplay.js';
|
||||
import { ScreenReaderAppLayout } from './layouts/ScreenReaderAppLayout.js';
|
||||
import { DefaultAppLayout } from './layouts/DefaultAppLayout.js';
|
||||
|
||||
const getContainerWidth = (terminalWidth: number): string => {
|
||||
if (terminalWidth <= 80) {
|
||||
return '98%';
|
||||
}
|
||||
if (terminalWidth >= 132) {
|
||||
return '90%';
|
||||
}
|
||||
|
||||
// Linearly interpolate between 80 columns (98%) and 132 columns (90%).
|
||||
const t = (terminalWidth - 80) / (132 - 80);
|
||||
const percentage = lerp(98, 90, t);
|
||||
|
||||
return `${Math.round(percentage)}%`;
|
||||
};
|
||||
|
||||
export const App = () => {
|
||||
const uiState = useUIState();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const { columns } = useTerminalSize();
|
||||
const containerWidth = getContainerWidth(columns);
|
||||
|
||||
if (uiState.quittingMessages) {
|
||||
return <QuittingDisplay />;
|
||||
@@ -21,7 +40,11 @@ export const App = () => {
|
||||
|
||||
return (
|
||||
<StreamingContext.Provider value={uiState.streamingState}>
|
||||
{isScreenReaderEnabled ? <ScreenReaderAppLayout /> : <DefaultAppLayout />}
|
||||
{isScreenReaderEnabled ? (
|
||||
<ScreenReaderAppLayout />
|
||||
) : (
|
||||
<DefaultAppLayout width={containerWidth} />
|
||||
)}
|
||||
</StreamingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -294,7 +294,10 @@ describe('AppContainer State Management', () => {
|
||||
// Mock LoadedSettings
|
||||
mockSettings = {
|
||||
merged: {
|
||||
hideBanner: false,
|
||||
hideFooter: false,
|
||||
hideTips: false,
|
||||
showMemoryUsage: false,
|
||||
theme: 'default',
|
||||
ui: {
|
||||
showStatusInTitle: false,
|
||||
@@ -442,7 +445,10 @@ describe('AppContainer State Management', () => {
|
||||
it('handles settings with all display options disabled', () => {
|
||||
const settingsAllHidden = {
|
||||
merged: {
|
||||
hideBanner: true,
|
||||
hideFooter: true,
|
||||
hideTips: true,
|
||||
showMemoryUsage: false,
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
@@ -457,6 +463,28 @@ describe('AppContainer State Management', () => {
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles settings with memory usage enabled', () => {
|
||||
const settingsWithMemory = {
|
||||
merged: {
|
||||
hideBanner: false,
|
||||
hideFooter: false,
|
||||
hideTips: false,
|
||||
showMemoryUsage: true,
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={settingsWithMemory}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Version Handling', () => {
|
||||
|
||||
@@ -45,7 +45,6 @@ import process from 'node:process';
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
|
||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||
import { useFeedbackDialog } from './hooks/useFeedbackDialog.js';
|
||||
import { useAuthCommand } from './auth/useAuth.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
@@ -272,8 +271,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
calculatePromptWidths(terminalWidth);
|
||||
return { inputWidth, suggestionsWidth };
|
||||
}, [terminalWidth]);
|
||||
// Uniform width for bordered box components: accounts for margins and caps at 100
|
||||
const mainAreaWidth = Math.min(terminalWidth - 4, 100);
|
||||
const mainAreaWidth = Math.floor(terminalWidth * 0.9);
|
||||
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
|
||||
|
||||
const isValidPath = useCallback((filePath: string): boolean => {
|
||||
@@ -577,6 +575,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
config.getExtensionContextFilePaths(),
|
||||
config.isTrustedFolder(),
|
||||
settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree'
|
||||
config.getFileFilteringOptions(),
|
||||
);
|
||||
|
||||
config.setUserMemory(memoryContent);
|
||||
@@ -1197,19 +1196,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isApprovalModeDialogOpen ||
|
||||
isResumeDialogOpen;
|
||||
|
||||
const {
|
||||
isFeedbackDialogOpen,
|
||||
openFeedbackDialog,
|
||||
closeFeedbackDialog,
|
||||
submitFeedback,
|
||||
} = useFeedbackDialog({
|
||||
config,
|
||||
settings,
|
||||
streamingState,
|
||||
history: historyManager.history,
|
||||
sessionStats,
|
||||
});
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
|
||||
@@ -1306,8 +1292,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen,
|
||||
isAgentsManagerDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
}),
|
||||
[
|
||||
isThemeDialogOpen,
|
||||
@@ -1398,15 +1382,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen,
|
||||
isAgentsManagerDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
],
|
||||
);
|
||||
|
||||
const uiActions: UIActions = useMemo(
|
||||
() => ({
|
||||
openThemeDialog,
|
||||
openEditorDialog,
|
||||
handleThemeSelect,
|
||||
handleThemeHighlight,
|
||||
handleApprovalModeSelect,
|
||||
@@ -1442,14 +1422,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
// Feedback dialog
|
||||
openFeedbackDialog,
|
||||
closeFeedbackDialog,
|
||||
submitFeedback,
|
||||
}),
|
||||
[
|
||||
openThemeDialog,
|
||||
openEditorDialog,
|
||||
handleThemeSelect,
|
||||
handleThemeHighlight,
|
||||
handleApprovalModeSelect,
|
||||
@@ -1483,10 +1457,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
// Feedback dialog
|
||||
openFeedbackDialog,
|
||||
closeFeedbackDialog,
|
||||
submitFeedback,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { useUIActions } from './contexts/UIActionsContext.js';
|
||||
import { useUIState } from './contexts/UIStateContext.js';
|
||||
import { useKeypress } from './hooks/useKeypress.js';
|
||||
|
||||
const FEEDBACK_OPTIONS = {
|
||||
GOOD: 1,
|
||||
BAD: 2,
|
||||
NOT_SURE: 3,
|
||||
} as const;
|
||||
|
||||
const FEEDBACK_OPTION_KEYS = {
|
||||
[FEEDBACK_OPTIONS.GOOD]: '1',
|
||||
[FEEDBACK_OPTIONS.BAD]: '2',
|
||||
[FEEDBACK_OPTIONS.NOT_SURE]: 'any',
|
||||
} as const;
|
||||
|
||||
export const FEEDBACK_DIALOG_KEYS = ['1', '2'] as const;
|
||||
|
||||
export const FeedbackDialog: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]) {
|
||||
uiActions.submitFeedback(FEEDBACK_OPTIONS.GOOD);
|
||||
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]) {
|
||||
uiActions.submitFeedback(FEEDBACK_OPTIONS.BAD);
|
||||
} else {
|
||||
uiActions.submitFeedback(FEEDBACK_OPTIONS.NOT_SURE);
|
||||
}
|
||||
|
||||
uiActions.closeFeedbackDialog();
|
||||
},
|
||||
{ isActive: uiState.isFeedbackDialogOpen },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Box>
|
||||
<Text color="cyan">● </Text>
|
||||
<Text bold>{t('How is Qwen doing this session? (optional)')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="cyan">
|
||||
{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]}:{' '}
|
||||
</Text>
|
||||
<Text>{t('Good')}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="cyan">{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]}: </Text>
|
||||
<Text>{t('Bad')}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="cyan">{t('Any other key')}: </Text>
|
||||
<Text>{t('Not Sure Yet')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -4,11 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
Config,
|
||||
ContentGeneratorConfig,
|
||||
ModelProvidersConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthEvent,
|
||||
AuthType,
|
||||
@@ -218,19 +214,11 @@ export const useAuthCommand = (
|
||||
|
||||
if (authType === AuthType.USE_OPENAI) {
|
||||
if (credentials) {
|
||||
// Pass settings.model.generationConfig to updateCredentials so it can be merged
|
||||
// after clearing provider-sourced config. This ensures settings.json generationConfig
|
||||
// fields (e.g., samplingParams, timeout) are preserved.
|
||||
const settingsGenerationConfig = settings.merged.model
|
||||
?.generationConfig as Partial<ContentGeneratorConfig> | undefined;
|
||||
config.updateCredentials(
|
||||
{
|
||||
apiKey: credentials.apiKey,
|
||||
baseUrl: credentials.baseUrl,
|
||||
model: credentials.model,
|
||||
},
|
||||
settingsGenerationConfig,
|
||||
);
|
||||
config.updateCredentials({
|
||||
apiKey: credentials.apiKey,
|
||||
baseUrl: credentials.baseUrl,
|
||||
model: credentials.model,
|
||||
});
|
||||
await performAuth(authType, credentials);
|
||||
}
|
||||
return;
|
||||
@@ -238,13 +226,7 @@ export const useAuthCommand = (
|
||||
|
||||
await performAuth(authType);
|
||||
},
|
||||
[
|
||||
config,
|
||||
performAuth,
|
||||
isProviderManagedModel,
|
||||
onAuthError,
|
||||
settings.merged.model?.generationConfig,
|
||||
],
|
||||
[config, performAuth, isProviderManagedModel, onAuthError],
|
||||
);
|
||||
|
||||
const openAuthDialog = useCallback(() => {
|
||||
|
||||
@@ -64,8 +64,7 @@ describe('aboutCommand', () => {
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(aboutCommand.name).toBe('status');
|
||||
expect(aboutCommand.altNames).toEqual(['about']);
|
||||
expect(aboutCommand.name).toBe('about');
|
||||
expect(aboutCommand.description).toBe('show version info');
|
||||
});
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const aboutCommand: SlashCommand = {
|
||||
name: 'status',
|
||||
altNames: ['about'],
|
||||
name: 'about',
|
||||
get description() {
|
||||
return t('show version info');
|
||||
},
|
||||
|
||||
@@ -56,22 +56,27 @@ describe('bugCommand', () => {
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(mockContext, 'A test bug');
|
||||
|
||||
const qwenCodeLine =
|
||||
const gitCommitLine =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? `Qwen Code: 0.1.0 (${GIT_COMMIT_INFO})`
|
||||
: 'Qwen Code: 0.1.0';
|
||||
const expectedInfo = `${qwenCodeLine}
|
||||
Runtime: Node.js v20.0.0 / npm 10.0.0
|
||||
IDE Client: VSCode
|
||||
OS: test-platform x64 (22.0.0)
|
||||
Model: qwen3-coder-plus
|
||||
Session ID: test-session-id
|
||||
Sandbox: test
|
||||
Proxy: no proxy
|
||||
Memory Usage: 100 MB`;
|
||||
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
|
||||
: '';
|
||||
const expectedInfo = `
|
||||
* **CLI Version:** 0.1.0
|
||||
${gitCommitLine}* **Model:** qwen3-coder-plus
|
||||
* **Sandbox:** test
|
||||
* **OS Platform:** test-platform
|
||||
* **OS Arch:** x64
|
||||
* **OS Release:** 22.0.0
|
||||
* **Node.js Version:** v20.0.0
|
||||
* **NPM Version:** 10.0.0
|
||||
* **Session ID:** test-session-id
|
||||
* **Auth Method:**
|
||||
* **Memory Usage:** 100 MB
|
||||
* **IDE Client:** VSCode
|
||||
`;
|
||||
const expectedUrl =
|
||||
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title=A%20test%20bug&info=' +
|
||||
encodeURIComponent(`\n${expectedInfo}\n`);
|
||||
encodeURIComponent(expectedInfo);
|
||||
|
||||
expect(open).toHaveBeenCalledWith(expectedUrl);
|
||||
});
|
||||
@@ -90,22 +95,27 @@ Memory Usage: 100 MB`;
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(mockContext, 'A custom bug');
|
||||
|
||||
const qwenCodeLine =
|
||||
const gitCommitLine =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? `Qwen Code: 0.1.0 (${GIT_COMMIT_INFO})`
|
||||
: 'Qwen Code: 0.1.0';
|
||||
const expectedInfo = `${qwenCodeLine}
|
||||
Runtime: Node.js v20.0.0 / npm 10.0.0
|
||||
IDE Client: VSCode
|
||||
OS: test-platform x64 (22.0.0)
|
||||
Model: qwen3-coder-plus
|
||||
Session ID: test-session-id
|
||||
Sandbox: test
|
||||
Proxy: no proxy
|
||||
Memory Usage: 100 MB`;
|
||||
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
|
||||
: '';
|
||||
const expectedInfo = `
|
||||
* **CLI Version:** 0.1.0
|
||||
${gitCommitLine}* **Model:** qwen3-coder-plus
|
||||
* **Sandbox:** test
|
||||
* **OS Platform:** test-platform
|
||||
* **OS Arch:** x64
|
||||
* **OS Release:** 22.0.0
|
||||
* **Node.js Version:** v20.0.0
|
||||
* **NPM Version:** 10.0.0
|
||||
* **Session ID:** test-session-id
|
||||
* **Auth Method:**
|
||||
* **Memory Usage:** 100 MB
|
||||
* **IDE Client:** VSCode
|
||||
`;
|
||||
const expectedUrl = customTemplate
|
||||
.replace('{title}', encodeURIComponent('A custom bug'))
|
||||
.replace('{info}', encodeURIComponent(`\n${expectedInfo}\n`));
|
||||
.replace('{info}', encodeURIComponent(expectedInfo));
|
||||
|
||||
expect(open).toHaveBeenCalledWith(expectedUrl);
|
||||
});
|
||||
@@ -142,23 +152,28 @@ Memory Usage: 100 MB`;
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(mockContext, 'OpenAI bug');
|
||||
|
||||
const qwenCodeLine =
|
||||
const gitCommitLine =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? `Qwen Code: 0.1.0 (${GIT_COMMIT_INFO})`
|
||||
: 'Qwen Code: 0.1.0';
|
||||
const expectedInfo = `${qwenCodeLine}
|
||||
Runtime: Node.js v20.0.0 / npm 10.0.0
|
||||
IDE Client: VSCode
|
||||
OS: test-platform x64 (22.0.0)
|
||||
Auth: ${AuthType.USE_OPENAI} (https://api.openai.com/v1)
|
||||
Model: qwen3-coder-plus
|
||||
Session ID: test-session-id
|
||||
Sandbox: test
|
||||
Proxy: no proxy
|
||||
Memory Usage: 100 MB`;
|
||||
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
|
||||
: '';
|
||||
const expectedInfo = `
|
||||
* **CLI Version:** 0.1.0
|
||||
${gitCommitLine}* **Model:** qwen3-coder-plus
|
||||
* **Sandbox:** test
|
||||
* **OS Platform:** test-platform
|
||||
* **OS Arch:** x64
|
||||
* **OS Release:** 22.0.0
|
||||
* **Node.js Version:** v20.0.0
|
||||
* **NPM Version:** 10.0.0
|
||||
* **Session ID:** test-session-id
|
||||
* **Auth Method:** ${AuthType.USE_OPENAI}
|
||||
* **Base URL:** https://api.openai.com/v1
|
||||
* **Memory Usage:** 100 MB
|
||||
* **IDE Client:** VSCode
|
||||
`;
|
||||
const expectedUrl =
|
||||
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title=OpenAI%20bug&info=' +
|
||||
encodeURIComponent(`\n${expectedInfo}\n`);
|
||||
encodeURIComponent(expectedInfo);
|
||||
|
||||
expect(open).toHaveBeenCalledWith(expectedUrl);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
|
||||
import { getSystemInfoFields } from '../../utils/systemInfoFields.js';
|
||||
import {
|
||||
getSystemInfoFields,
|
||||
getFieldValue,
|
||||
} from '../../utils/systemInfoFields.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const bugCommand: SlashCommand = {
|
||||
@@ -27,9 +30,11 @@ export const bugCommand: SlashCommand = {
|
||||
|
||||
const fields = getSystemInfoFields(systemInfo);
|
||||
|
||||
const info = fields
|
||||
.map((field) => `${field.label}: ${field.value}`)
|
||||
.join('\n');
|
||||
// Generate bug report info using the same field configuration
|
||||
let info = '\n';
|
||||
for (const field of fields) {
|
||||
info += `* **${field.label}:** ${getFieldValue(field, systemInfo)}\n`;
|
||||
}
|
||||
|
||||
let bugReportUrl =
|
||||
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}';
|
||||
@@ -41,7 +46,7 @@ export const bugCommand: SlashCommand = {
|
||||
|
||||
bugReportUrl = bugReportUrl
|
||||
.replace('{title}', encodeURIComponent(bugDescription))
|
||||
.replace('{info}', encodeURIComponent(`\n${info}\n`));
|
||||
.replace('{info}', encodeURIComponent(info));
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
|
||||
@@ -54,7 +54,9 @@ describe('directoryCommand', () => {
|
||||
services: {
|
||||
config: mockConfig,
|
||||
settings: {
|
||||
merged: {},
|
||||
merged: {
|
||||
memoryDiscoveryMaxDirs: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
|
||||
@@ -119,6 +119,8 @@ export const directoryCommand: SlashCommand = {
|
||||
config.getFolderTrust(),
|
||||
context.services.settings.merged.context?.importFormat ||
|
||||
'tree', // Use setting or default to 'tree'
|
||||
config.getFileFilteringOptions(),
|
||||
context.services.settings.merged.context?.discoveryMaxDirs,
|
||||
);
|
||||
config.setUserMemory(memoryContent);
|
||||
config.setGeminiMdFileCount(fileCount);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import { type CommandContext, CommandKind } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../i18n/index.js', () => ({
|
||||
@@ -72,8 +71,10 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
|
||||
// Import modules after mocking
|
||||
import * as i18n from '../../i18n/index.js';
|
||||
import { languageCommand } from './languageCommand.js';
|
||||
import { initializeLlmOutputLanguage } from '../../utils/languageUtils.js';
|
||||
import {
|
||||
languageCommand,
|
||||
initializeLlmOutputLanguage,
|
||||
} from './languageCommand.js';
|
||||
|
||||
describe('languageCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
@@ -164,13 +165,11 @@ describe('languageCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should show LLM output language when explicitly set', async () => {
|
||||
// Set the outputLanguage setting explicitly
|
||||
mockContext.services.settings = {
|
||||
...mockContext.services.settings,
|
||||
merged: { general: { outputLanguage: 'Chinese' } },
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
it('should show LLM output language when set', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
'# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY',
|
||||
);
|
||||
|
||||
// Make t() function handle interpolation for this test
|
||||
vi.mocked(i18n.t).mockImplementation(
|
||||
@@ -193,7 +192,7 @@ describe('languageCommand', () => {
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Current UI language:'),
|
||||
});
|
||||
// Verify it shows "Chinese" for the explicitly set language
|
||||
// Verify it correctly parses "Chinese" from the template format
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
@@ -201,14 +200,16 @@ describe('languageCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should show auto-detected language when set to auto', async () => {
|
||||
// Set the outputLanguage setting to 'auto'
|
||||
mockContext.services.settings = {
|
||||
...mockContext.services.settings,
|
||||
merged: { general: { outputLanguage: 'auto' } },
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh');
|
||||
it('should parse Unicode LLM output language from marker', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
[
|
||||
'# ⚠️ CRITICAL: 中文 Output Language Rule - HIGHEST PRIORITY ⚠️',
|
||||
'<!-- qwen-code:llm-output-language: 中文 -->',
|
||||
'',
|
||||
'Some other content...',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
vi.mocked(i18n.t).mockImplementation(
|
||||
(key: string, params?: Record<string, string>) => {
|
||||
@@ -225,16 +226,10 @@ describe('languageCommand', () => {
|
||||
|
||||
const result = await languageCommand.action(mockContext, '');
|
||||
|
||||
// Verify it shows "Auto (detect from system) → Chinese"
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Auto (detect from system)'),
|
||||
});
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Chinese'),
|
||||
content: expect.stringContaining('中文'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -409,7 +404,7 @@ describe('languageCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should save LLM output language setting', async () => {
|
||||
it('should create LLM output language rule file', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
@@ -419,16 +414,18 @@ describe('languageCommand', () => {
|
||||
'output Chinese',
|
||||
);
|
||||
|
||||
// Verify setting was saved (rule file is updated on restart)
|
||||
expect(mockContext.services.settings?.setValue).toHaveBeenCalledWith(
|
||||
expect.anything(), // SettingScope.User
|
||||
'general.outputLanguage',
|
||||
'Chinese',
|
||||
expect(fs.mkdirSync).toHaveBeenCalled();
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('output-language.md'),
|
||||
expect.stringContaining('Chinese'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('LLM output language set to'),
|
||||
content: expect.stringContaining(
|
||||
'LLM output language rule file generated',
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -456,11 +453,10 @@ describe('languageCommand', () => {
|
||||
|
||||
await languageCommand.action(mockContext, 'output ru');
|
||||
|
||||
// Verify setting was saved with normalized value
|
||||
expect(mockContext.services.settings?.setValue).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'general.outputLanguage',
|
||||
'Russian',
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('output-language.md'),
|
||||
expect.stringContaining('Russian'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -471,36 +467,28 @@ describe('languageCommand', () => {
|
||||
|
||||
await languageCommand.action(mockContext, 'output de');
|
||||
|
||||
// Verify setting was saved with normalized value
|
||||
expect(mockContext.services.settings?.setValue).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'general.outputLanguage',
|
||||
'German',
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('output-language.md'),
|
||||
expect.stringContaining('German'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should save setting without immediate rule file update', async () => {
|
||||
// Even though rule file updates happen on restart, the setting should still be saved
|
||||
it('should handle file write errors gracefully', async () => {
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(
|
||||
mockContext,
|
||||
'output Spanish',
|
||||
);
|
||||
const result = await languageCommand.action(mockContext, 'output German');
|
||||
|
||||
// Verify setting was saved
|
||||
expect(mockContext.services.settings?.setValue).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'general.outputLanguage',
|
||||
'Spanish',
|
||||
);
|
||||
// Verify success message (no error about file generation)
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('LLM output language set to'),
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Failed to generate'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -598,23 +586,24 @@ describe('languageCommand', () => {
|
||||
expect(outputSubcommand?.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
it('should have action that saves setting', async () => {
|
||||
it('should have action that generates rule file', async () => {
|
||||
if (!outputSubcommand?.action) {
|
||||
throw new Error('Output subcommand must have an action.');
|
||||
}
|
||||
|
||||
// Ensure mocks are properly set for this test
|
||||
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||
|
||||
const result = await outputSubcommand.action(mockContext, 'French');
|
||||
|
||||
// Verify setting was saved (rule file is updated on restart)
|
||||
expect(mockContext.services.settings?.setValue).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'general.outputLanguage',
|
||||
'French',
|
||||
);
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('LLM output language set to'),
|
||||
content: expect.stringContaining(
|
||||
'LLM output language rule file generated',
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -699,7 +688,6 @@ describe('languageCommand', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() => '');
|
||||
});
|
||||
|
||||
it('should create file when it does not exist', () => {
|
||||
@@ -716,50 +704,14 @@ describe('languageCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT overwrite existing file when content matches resolved language', () => {
|
||||
it('should NOT overwrite existing file', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('en');
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
`# Output language preference: English
|
||||
<!-- qwen-code:llm-output-language: English -->
|
||||
`,
|
||||
);
|
||||
|
||||
initializeLlmOutputLanguage();
|
||||
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should overwrite existing file when output language setting differs', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
`# Output language preference: English
|
||||
<!-- qwen-code:llm-output-language: English -->
|
||||
`,
|
||||
);
|
||||
|
||||
initializeLlmOutputLanguage('Japanese');
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('output-language.md'),
|
||||
expect.stringContaining('Japanese'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve auto setting to detected system language', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh');
|
||||
|
||||
initializeLlmOutputLanguage('auto');
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('output-language.md'),
|
||||
expect.stringContaining('Chinese'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect Chinese locale and create Chinese rule file', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh');
|
||||
|
||||
@@ -15,40 +15,25 @@ import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
setLanguageAsync,
|
||||
getCurrentLanguage,
|
||||
detectSystemLanguage,
|
||||
getLanguageNameFromLocale,
|
||||
type SupportedLanguage,
|
||||
t,
|
||||
} from '../../i18n/index.js';
|
||||
import { SUPPORTED_LANGUAGES } from '../../i18n/languages.js';
|
||||
import {
|
||||
OUTPUT_LANGUAGE_AUTO,
|
||||
isAutoLanguage,
|
||||
resolveOutputLanguage,
|
||||
updateOutputLanguageFile,
|
||||
} from '../../utils/languageUtils.js';
|
||||
SUPPORTED_LANGUAGES,
|
||||
type LanguageDefinition,
|
||||
} from '../../i18n/languages.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { Storage } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Gets the current LLM output language setting and its resolved value.
|
||||
* Returns an object with both the raw setting and the resolved language.
|
||||
*/
|
||||
function getCurrentOutputLanguage(context?: CommandContext): {
|
||||
setting: string;
|
||||
resolved: string;
|
||||
} {
|
||||
const settingValue =
|
||||
context?.services?.settings?.merged?.general?.outputLanguage ||
|
||||
OUTPUT_LANGUAGE_AUTO;
|
||||
const resolved = resolveOutputLanguage(settingValue);
|
||||
return { setting: settingValue, resolved };
|
||||
}
|
||||
const LLM_OUTPUT_LANGUAGE_RULE_FILENAME = 'output-language.md';
|
||||
const LLM_OUTPUT_LANGUAGE_MARKER_PREFIX = 'qwen-code:llm-output-language:';
|
||||
|
||||
/**
|
||||
* Parses user input to find a matching supported UI language.
|
||||
* Accepts locale codes (e.g., "zh"), IDs (e.g., "zh-CN"), or full names (e.g., "Chinese").
|
||||
*/
|
||||
function parseUiLanguageArg(input: string): SupportedLanguage | null {
|
||||
const lowered = input.trim().toLowerCase();
|
||||
if (!lowered) return null;
|
||||
|
||||
for (const lang of SUPPORTED_LANGUAGES) {
|
||||
if (
|
||||
lowered === lang.code ||
|
||||
@@ -61,22 +46,153 @@ function parseUiLanguageArg(input: string): SupportedLanguage | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a UI language code for display (e.g., "zh" -> "Chinese(zh-CN)").
|
||||
*/
|
||||
function formatUiLanguageDisplay(lang: SupportedLanguage): string {
|
||||
const option = SUPPORTED_LANGUAGES.find((o) => o.code === lang);
|
||||
return option ? `${option.fullName}(${option.id})` : lang;
|
||||
}
|
||||
|
||||
function sanitizeLanguageForMarker(language: string): string {
|
||||
// HTML comments cannot contain "--" or end markers like "-->" or "--!>" safely.
|
||||
// Also avoid newlines to keep the marker single-line and robust to parsing.
|
||||
return language
|
||||
.replace(/[\r\n]/g, ' ')
|
||||
.replace(/--!?>/g, '')
|
||||
.replace(/--/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the UI language and persists it to user settings.
|
||||
* Generates the LLM output language rule template based on the language name.
|
||||
*/
|
||||
function generateLlmOutputLanguageRule(language: string): string {
|
||||
const markerLanguage = sanitizeLanguageForMarker(language);
|
||||
return `# Output language preference: ${language}
|
||||
<!-- ${LLM_OUTPUT_LANGUAGE_MARKER_PREFIX} ${markerLanguage} -->
|
||||
|
||||
## Goal
|
||||
Prefer responding in **${language}** for normal assistant messages and explanations.
|
||||
|
||||
## Keep technical artifacts unchanged
|
||||
Do **not** translate or rewrite:
|
||||
- Code blocks, CLI commands, file paths, stack traces, logs, JSON keys, identifiers
|
||||
- Exact quoted text from the user (keep quotes verbatim)
|
||||
|
||||
## When a conflict exists
|
||||
If higher-priority instructions (system/developer) require a different behavior, follow them.
|
||||
|
||||
## Tool / system outputs
|
||||
Raw tool/system outputs may contain fixed-format English. Preserve them verbatim, and if needed, add a short **${language}** explanation below.
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the LLM output language rule file.
|
||||
*/
|
||||
function getLlmOutputLanguageRulePath(): string {
|
||||
return path.join(
|
||||
Storage.getGlobalQwenDir(),
|
||||
LLM_OUTPUT_LANGUAGE_RULE_FILENAME,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a language input to its full English name.
|
||||
* If the input is a known locale code (e.g., "ru", "zh"), converts it to the full name.
|
||||
* Otherwise, returns the input as-is (e.g., "Japanese" stays "Japanese").
|
||||
*/
|
||||
function normalizeLanguageName(language: string): string {
|
||||
const lowered = language.toLowerCase();
|
||||
// Check if it's a known locale code and convert to full name
|
||||
const fullName = getLanguageNameFromLocale(lowered);
|
||||
// If getLanguageNameFromLocale returned a different value, use it
|
||||
// Otherwise, use the original input (preserves case for unknown languages)
|
||||
if (fullName !== 'English' || lowered === 'en') {
|
||||
return fullName;
|
||||
}
|
||||
return language;
|
||||
}
|
||||
|
||||
function extractLlmOutputLanguageFromRuleFileContent(
|
||||
content: string,
|
||||
): string | null {
|
||||
// Preferred: machine-readable marker that supports Unicode and spaces.
|
||||
// Example: <!-- qwen-code:llm-output-language: 中文 -->
|
||||
const markerMatch = content.match(
|
||||
new RegExp(
|
||||
String.raw`<!--\s*${LLM_OUTPUT_LANGUAGE_MARKER_PREFIX}\s*(.*?)\s*-->`,
|
||||
'i',
|
||||
),
|
||||
);
|
||||
if (markerMatch?.[1]) {
|
||||
const lang = markerMatch[1].trim();
|
||||
if (lang) return lang;
|
||||
}
|
||||
|
||||
// Backward compatibility: parse the heading line.
|
||||
// Example: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY"
|
||||
// Example: "# ⚠️ CRITICAL: 日本語 Output Language Rule - HIGHEST PRIORITY ⚠️"
|
||||
const headingMatch = content.match(
|
||||
/^#.*?CRITICAL:\s*(.*?)\s+Output Language Rule\b/im,
|
||||
);
|
||||
if (headingMatch?.[1]) {
|
||||
const lang = headingMatch[1].trim();
|
||||
if (lang) return lang;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the LLM output language rule file on first startup.
|
||||
* If the file already exists, it is not overwritten (respects user preference).
|
||||
*/
|
||||
export function initializeLlmOutputLanguage(): void {
|
||||
const filePath = getLlmOutputLanguageRulePath();
|
||||
|
||||
// Skip if file already exists (user preference)
|
||||
if (fs.existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect system language and map to language name
|
||||
const detectedLocale = detectSystemLanguage();
|
||||
const languageName = getLanguageNameFromLocale(detectedLocale);
|
||||
|
||||
// Generate the rule file
|
||||
const content = generateLlmOutputLanguageRule(languageName);
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
// Write file
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current LLM output language from the rule file if it exists.
|
||||
*/
|
||||
function getCurrentLlmOutputLanguage(): string | null {
|
||||
const filePath = getLlmOutputLanguageRulePath();
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return extractLlmOutputLanguageFromRuleFileContent(content);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the UI language and persists it to settings.
|
||||
*/
|
||||
async function setUiLanguage(
|
||||
context: CommandContext,
|
||||
lang: SupportedLanguage,
|
||||
): Promise<MessageActionReturn> {
|
||||
const { services } = context;
|
||||
const { settings } = services;
|
||||
|
||||
if (!services.config) {
|
||||
return {
|
||||
@@ -86,19 +202,19 @@ async function setUiLanguage(
|
||||
};
|
||||
}
|
||||
|
||||
// Update i18n system
|
||||
// Set language in i18n system (async to support JS translation files)
|
||||
await setLanguageAsync(lang);
|
||||
|
||||
// Persist to settings
|
||||
if (services.settings?.setValue) {
|
||||
// Persist to settings (user scope)
|
||||
if (settings && typeof settings.setValue === 'function') {
|
||||
try {
|
||||
services.settings.setValue(SettingScope.User, 'general.language', lang);
|
||||
settings.setValue(SettingScope.User, 'general.language', lang);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save language setting:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reload commands to update localized descriptions
|
||||
// Reload commands to update their descriptions with the new language
|
||||
context.ui.reloadCommands();
|
||||
|
||||
return {
|
||||
@@ -111,51 +227,37 @@ async function setUiLanguage(
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the /language output command, updating both the setting and the rule file.
|
||||
* 'auto' is preserved in settings but resolved to the detected language for the rule file.
|
||||
* Generates the LLM output language rule file.
|
||||
*/
|
||||
async function setOutputLanguage(
|
||||
context: CommandContext,
|
||||
function generateLlmOutputLanguageRuleFile(
|
||||
language: string,
|
||||
): Promise<MessageActionReturn> {
|
||||
try {
|
||||
const isAuto = isAutoLanguage(language);
|
||||
const resolved = resolveOutputLanguage(language);
|
||||
// Save 'auto' as-is to settings, or normalize other values
|
||||
const settingValue = isAuto ? OUTPUT_LANGUAGE_AUTO : resolved;
|
||||
const filePath = getLlmOutputLanguageRulePath();
|
||||
// Normalize locale codes (e.g., "ru" -> "Russian") to full language names
|
||||
const normalizedLanguage = normalizeLanguageName(language);
|
||||
const content = generateLlmOutputLanguageRule(normalizedLanguage);
|
||||
|
||||
// Update the rule file with the resolved language
|
||||
updateOutputLanguageFile(settingValue);
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
// Save to settings
|
||||
if (context.services.settings?.setValue) {
|
||||
try {
|
||||
context.services.settings.setValue(
|
||||
SettingScope.User,
|
||||
'general.outputLanguage',
|
||||
settingValue,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save output language setting:', error);
|
||||
}
|
||||
}
|
||||
// Write file (overwrite if exists)
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
|
||||
// Format display message
|
||||
const displayLang = isAuto
|
||||
? `${t('Auto (detect from system)')} → ${resolved}`
|
||||
: resolved;
|
||||
|
||||
return {
|
||||
return Promise.resolve({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: [
|
||||
t('LLM output language set to {{lang}}', { lang: displayLang }),
|
||||
t('LLM output language rule file generated at {{path}}', {
|
||||
path: filePath,
|
||||
}),
|
||||
'',
|
||||
t('Please restart the application for the changes to take effect.'),
|
||||
].join('\n'),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
return Promise.resolve({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
@@ -164,7 +266,7 @@ async function setOutputLanguage(
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,12 +276,12 @@ export const languageCommand: SlashCommand = {
|
||||
return t('View or change the language setting');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
if (!context.services.config) {
|
||||
const { services } = context;
|
||||
if (!services.config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
@@ -189,83 +291,75 @@ export const languageCommand: SlashCommand = {
|
||||
|
||||
const trimmedArgs = args.trim();
|
||||
|
||||
// Route to subcommands if specified
|
||||
if (trimmedArgs) {
|
||||
const [firstArg, ...rest] = trimmedArgs.split(/\s+/);
|
||||
const subCommandName = firstArg.toLowerCase();
|
||||
const subArgs = rest.join(' ');
|
||||
// Handle subcommands if called directly via action (for tests/backward compatibility)
|
||||
const parts = trimmedArgs.split(/\s+/);
|
||||
const firstArg = parts[0].toLowerCase();
|
||||
const subArgs = parts.slice(1).join(' ');
|
||||
|
||||
if (subCommandName === 'ui' || subCommandName === 'output') {
|
||||
const subCommand = languageCommand.subCommands?.find(
|
||||
(s) => s.name === subCommandName,
|
||||
);
|
||||
if (subCommand?.action) {
|
||||
return subCommand.action(
|
||||
context,
|
||||
subArgs,
|
||||
) as Promise<SlashCommandActionReturn>;
|
||||
}
|
||||
if (firstArg === 'ui' || firstArg === 'output') {
|
||||
const subCommand = languageCommand.subCommands?.find(
|
||||
(s) => s.name === firstArg,
|
||||
);
|
||||
if (subCommand?.action) {
|
||||
return subCommand.action(
|
||||
context,
|
||||
subArgs,
|
||||
) as Promise<SlashCommandActionReturn>;
|
||||
}
|
||||
|
||||
// Backward compatibility: direct language code (e.g., /language zh)
|
||||
const targetLang = parseUiLanguageArg(trimmedArgs);
|
||||
if (targetLang) {
|
||||
return setUiLanguage(context, targetLang);
|
||||
}
|
||||
|
||||
// Unknown argument
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: [
|
||||
t('Invalid command. Available subcommands:'),
|
||||
` - /language ui [${SUPPORTED_LANGUAGES.map((o) => o.id).join('|')}] - ${t('Set UI language')}`,
|
||||
` - /language output <language> - ${t('Set LLM output language')}`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
// No arguments: show current status
|
||||
const currentUiLang = getCurrentLanguage();
|
||||
const { setting: outputSetting, resolved: outputResolved } =
|
||||
getCurrentOutputLanguage(context);
|
||||
|
||||
// Format output language display: show "Auto → English" or just "English"
|
||||
const outputLangDisplay = isAutoLanguage(outputSetting)
|
||||
? `${t('Auto (detect from system)')} → ${outputResolved}`
|
||||
: outputResolved;
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: [
|
||||
// If no arguments, show current language settings and usage
|
||||
if (!trimmedArgs) {
|
||||
const currentUiLang = getCurrentLanguage();
|
||||
const currentLlmLang = getCurrentLlmOutputLanguage();
|
||||
const message = [
|
||||
t('Current UI language: {{lang}}', {
|
||||
lang: formatUiLanguageDisplay(currentUiLang as SupportedLanguage),
|
||||
}),
|
||||
t('Current LLM output language: {{lang}}', { lang: outputLangDisplay }),
|
||||
currentLlmLang
|
||||
? t('Current LLM output language: {{lang}}', { lang: currentLlmLang })
|
||||
: t('LLM output language not set'),
|
||||
'',
|
||||
t('Available subcommands:'),
|
||||
` /language ui [${SUPPORTED_LANGUAGES.map((o) => o.id).join('|')}] - ${t('Set UI language')}`,
|
||||
` /language output <language> - ${t('Set LLM output language')}`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: message,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle backward compatibility for /language [lang]
|
||||
const targetLang = parseUiLanguageArg(trimmedArgs);
|
||||
if (targetLang) {
|
||||
return setUiLanguage(context, targetLang);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: [
|
||||
t('Invalid command. Available subcommands:'),
|
||||
` - /language ui [${SUPPORTED_LANGUAGES.map((o) => o.id).join('|')}] - ${t('Set UI language')}`,
|
||||
' - /language output <language> - ' + t('Set LLM output language'),
|
||||
].join('\n'),
|
||||
};
|
||||
},
|
||||
|
||||
subCommands: [
|
||||
// /language ui subcommand
|
||||
{
|
||||
name: 'ui',
|
||||
get description() {
|
||||
return t('Set UI language');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
const trimmedArgs = args.trim();
|
||||
|
||||
if (!trimmedArgs) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -302,45 +396,19 @@ export const languageCommand: SlashCommand = {
|
||||
|
||||
return setUiLanguage(context, targetLang);
|
||||
},
|
||||
|
||||
// Nested subcommands for each supported language (e.g., /language ui zh-CN)
|
||||
subCommands: SUPPORTED_LANGUAGES.map(
|
||||
(lang): SlashCommand => ({
|
||||
name: lang.id,
|
||||
get description() {
|
||||
return t('Set UI language to {{name}}', { name: lang.fullName });
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, args) => {
|
||||
if (args.trim()) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Language subcommands do not accept additional arguments.',
|
||||
),
|
||||
};
|
||||
}
|
||||
return setUiLanguage(context, lang.code);
|
||||
},
|
||||
}),
|
||||
),
|
||||
subCommands: SUPPORTED_LANGUAGES.map(createUiLanguageSubCommand),
|
||||
},
|
||||
|
||||
// /language output subcommand
|
||||
{
|
||||
name: 'output',
|
||||
get description() {
|
||||
return t('Set LLM output language');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
const trimmedArgs = args.trim();
|
||||
|
||||
if (!trimmedArgs) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -356,8 +424,33 @@ export const languageCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
|
||||
return setOutputLanguage(context, trimmedArgs);
|
||||
return generateLlmOutputLanguageRuleFile(trimmedArgs);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to create a UI language subcommand.
|
||||
*/
|
||||
function createUiLanguageSubCommand(option: LanguageDefinition): SlashCommand {
|
||||
return {
|
||||
name: option.id,
|
||||
get description() {
|
||||
return t('Set UI language to {{name}}', { name: option.fullName });
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, args) => {
|
||||
if (args.trim().length > 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Language subcommands do not accept additional arguments.',
|
||||
),
|
||||
};
|
||||
}
|
||||
return setUiLanguage(context, option.code);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -299,7 +299,9 @@ describe('memoryCommand', () => {
|
||||
services: {
|
||||
config: mockConfig,
|
||||
settings: {
|
||||
merged: {},
|
||||
merged: {
|
||||
memoryDiscoveryMaxDirs: 1000,
|
||||
},
|
||||
} as LoadedSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -315,6 +315,8 @@ export const memoryCommand: SlashCommand = {
|
||||
config.getFolderTrust(),
|
||||
context.services.settings.merged.context?.importFormat ||
|
||||
'tree', // Use setting or default to 'tree'
|
||||
config.getFileFilteringOptions(),
|
||||
context.services.settings.merged.context?.discoveryMaxDirs,
|
||||
);
|
||||
config.setUserMemory(memoryContent);
|
||||
config.setGeminiMdFileCount(fileCount);
|
||||
|
||||
@@ -8,14 +8,16 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { ExtendedSystemInfo } from '../../utils/systemInfo.js';
|
||||
import { getSystemInfoFields } from '../../utils/systemInfoFields.js';
|
||||
import {
|
||||
getSystemInfoFields,
|
||||
getFieldValue,
|
||||
type SystemInfoField,
|
||||
} from '../../utils/systemInfoFields.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
type AboutBoxProps = ExtendedSystemInfo & {
|
||||
width?: number;
|
||||
};
|
||||
type AboutBoxProps = ExtendedSystemInfo;
|
||||
|
||||
export const AboutBox: React.FC<AboutBoxProps> = ({ width, ...props }) => {
|
||||
export const AboutBox: React.FC<AboutBoxProps> = (props) => {
|
||||
const fields = getSystemInfoFields(props);
|
||||
|
||||
return (
|
||||
@@ -24,26 +26,25 @@ export const AboutBox: React.FC<AboutBoxProps> = ({ width, ...props }) => {
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width={width}
|
||||
marginY={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{t('Status')}
|
||||
{t('About Qwen Code')}
|
||||
</Text>
|
||||
</Box>
|
||||
{fields.map((field) => (
|
||||
<Box
|
||||
key={field.label}
|
||||
flexDirection="row"
|
||||
marginTop={field.label === t('Auth') ? 1 : 0}
|
||||
>
|
||||
{fields.map((field: SystemInfoField) => (
|
||||
<Box key={field.key} flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
{field.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{field.value}</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
{getFieldValue(field, props)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||
|
||||
const createSettings = (options?: { hideTips?: boolean }): LoadedSettings =>
|
||||
({
|
||||
merged: {
|
||||
ui: {
|
||||
hideTips: options?.hideTips ?? true,
|
||||
},
|
||||
},
|
||||
}) as never;
|
||||
|
||||
const createMockConfig = (overrides = {}) => ({
|
||||
getContentGeneratorConfig: vi.fn(() => ({ authType: undefined })),
|
||||
getModel: vi.fn(() => 'gemini-pro'),
|
||||
getTargetDir: vi.fn(() => '/projects/qwen-code'),
|
||||
getMcpServers: vi.fn(() => ({})),
|
||||
getBlockedMcpServers: vi.fn(() => []),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getScreenReader: vi.fn(() => false),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
({
|
||||
branchName: 'main',
|
||||
nightly: false,
|
||||
debugMessage: '',
|
||||
sessionStats: {
|
||||
lastPromptTokenCount: 0,
|
||||
},
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
const renderWithProviders = (
|
||||
uiState: UIState,
|
||||
settings = createSettings(),
|
||||
config = createMockConfig(),
|
||||
) => {
|
||||
useTerminalSizeMock.mockReturnValue({ columns: 120, rows: 24 });
|
||||
return render(
|
||||
<ConfigContext.Provider value={config as never}>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<VimModeProvider settings={settings}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<AppHeader version="1.2.3" />
|
||||
</UIStateContext.Provider>
|
||||
</VimModeProvider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AppHeader />', () => {
|
||||
it('shows the working directory', () => {
|
||||
const { lastFrame } = renderWithProviders(createMockUIState());
|
||||
expect(lastFrame()).toContain('/projects/qwen-code');
|
||||
});
|
||||
|
||||
it('hides the header when screen reader is enabled', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
createMockUIState(),
|
||||
createSettings(),
|
||||
createMockConfig({ getScreenReader: vi.fn(() => true) }),
|
||||
);
|
||||
// When screen reader is enabled, header is not rendered
|
||||
expect(lastFrame()).not.toContain('/projects/qwen-code');
|
||||
expect(lastFrame()).not.toContain('Qwen Code');
|
||||
});
|
||||
|
||||
it('shows the header with all info when banner is visible', () => {
|
||||
const { lastFrame } = renderWithProviders(createMockUIState());
|
||||
expect(lastFrame()).toContain('>_ Qwen Code');
|
||||
expect(lastFrame()).toContain('gemini-pro');
|
||||
expect(lastFrame()).toContain('/projects/qwen-code');
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { Header } from './Header.js';
|
||||
import { Tips } from './Tips.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
interface AppHeaderProps {
|
||||
version: string;
|
||||
@@ -17,25 +18,16 @@ interface AppHeaderProps {
|
||||
export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
|
||||
const contentGeneratorConfig = config.getContentGeneratorConfig();
|
||||
const authType = contentGeneratorConfig?.authType;
|
||||
const model = config.getModel();
|
||||
const targetDir = config.getTargetDir();
|
||||
const showBanner = !config.getScreenReader();
|
||||
const showTips = !(settings.merged.ui?.hideTips || config.getScreenReader());
|
||||
const { nightly } = useUIState();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{showBanner && (
|
||||
<Header
|
||||
version={version}
|
||||
authType={authType}
|
||||
model={model}
|
||||
workingDirectory={targetDir}
|
||||
/>
|
||||
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
|
||||
<Header version={version} nightly={nightly} />
|
||||
)}
|
||||
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
|
||||
<Tips config={config} />
|
||||
)}
|
||||
{showTips && <Tips />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ export function ApprovalModeDialog({
|
||||
}: ApprovalModeDialogProps): React.JSX.Element {
|
||||
// Start with User scope by default
|
||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||
SettingScope.User,
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
|
||||
// Track the currently highlighted approval mode
|
||||
@@ -90,17 +90,19 @@ export function ApprovalModeDialog({
|
||||
setSelectedScope(scope);
|
||||
}, []);
|
||||
|
||||
const handleScopeSelect = useCallback((scope: SettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
setMode('mode');
|
||||
}, []);
|
||||
const handleScopeSelect = useCallback(
|
||||
(scope: SettingScope) => {
|
||||
onSelect(highlightedMode, scope);
|
||||
},
|
||||
[onSelect, highlightedMode],
|
||||
);
|
||||
|
||||
const [mode, setMode] = useState<'mode' | 'scope'>('mode');
|
||||
const [focusSection, setFocusSection] = useState<'mode' | 'scope'>('mode');
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'tab') {
|
||||
setMode((prev) => (prev === 'mode' ? 'scope' : 'mode'));
|
||||
setFocusSection((prev) => (prev === 'mode' ? 'scope' : 'mode'));
|
||||
}
|
||||
if (key.name === 'escape') {
|
||||
onSelect(undefined, selectedScope);
|
||||
@@ -125,56 +127,59 @@ export function ApprovalModeDialog({
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
flexDirection="row"
|
||||
padding={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
{mode === 'mode' ? (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{/* Approval Mode Selection */}
|
||||
<Text bold={mode === 'mode'} wrap="truncate">
|
||||
{mode === 'mode' ? '> ' : ' '}
|
||||
{t('Approval Mode')}{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
{otherScopeModifiedMessage}
|
||||
</Text>
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<RadioButtonSelect
|
||||
items={modeItems}
|
||||
initialIndex={safeInitialModeIndex}
|
||||
onSelect={handleModeSelect}
|
||||
onHighlight={handleModeHighlight}
|
||||
isFocused={mode === 'mode'}
|
||||
maxItemsToShow={10}
|
||||
showScrollArrows={false}
|
||||
showNumbers={mode === 'mode'}
|
||||
/>
|
||||
{/* Warning when workspace setting will override user setting */}
|
||||
{showWorkspacePriorityWarning && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.warning} wrap="wrap">
|
||||
⚠{' '}
|
||||
{t(
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<ScopeSelector
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={mode === 'scope'}
|
||||
initialScope={selectedScope}
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{/* Approval Mode Selection */}
|
||||
<Text bold={focusSection === 'mode'} wrap="truncate">
|
||||
{focusSection === 'mode' ? '> ' : ' '}
|
||||
{t('Approval Mode')}{' '}
|
||||
<Text color={theme.text.secondary}>{otherScopeModifiedMessage}</Text>
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<RadioButtonSelect
|
||||
items={modeItems}
|
||||
initialIndex={safeInitialModeIndex}
|
||||
onSelect={handleModeSelect}
|
||||
onHighlight={handleModeHighlight}
|
||||
isFocused={focusSection === 'mode'}
|
||||
maxItemsToShow={10}
|
||||
showScrollArrows={false}
|
||||
showNumbers={focusSection === 'mode'}
|
||||
/>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
{mode === 'mode'
|
||||
? t('(Use Enter to select, Tab to configure scope)')
|
||||
: t('(Use Enter to apply scope, Tab to go back)')}
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Scope Selection */}
|
||||
<Box marginTop={1}>
|
||||
<ScopeSelector
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={focusSection === 'scope'}
|
||||
initialScope={selectedScope}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Warning when workspace setting will override user setting */}
|
||||
{showWorkspacePriorityWarning && (
|
||||
<>
|
||||
<Text color={theme.status.warning} wrap="wrap">
|
||||
⚠{' '}
|
||||
{t(
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.',
|
||||
)}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('(Use Enter to select, Tab to change focus)')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -5,10 +5,29 @@
|
||||
*/
|
||||
|
||||
export const shortAsciiLogo = `
|
||||
▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄▄ ▄▄▄ ▄▄
|
||||
██████╗ ██╗ ██╗███████╗███╗ ██╗
|
||||
██╔═══██╗██║ ██║██╔════╝████╗ ██║
|
||||
██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
|
||||
██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
|
||||
╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
|
||||
╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||
╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||
`;
|
||||
export const longAsciiLogo = `
|
||||
██╗ ██████╗ ██╗ ██╗███████╗███╗ ██╗
|
||||
╚██╗ ██╔═══██╗██║ ██║██╔════╝████╗ ██║
|
||||
╚██╗ ██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
|
||||
██╔╝ ██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
|
||||
██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
|
||||
╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||
`;
|
||||
|
||||
export const tinyAsciiLogo = `
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
`;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type UIActions,
|
||||
} from '../contexts/UIActionsContext.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
// Mock VimModeContext hook
|
||||
vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
useVimMode: vi.fn(() => ({
|
||||
@@ -145,33 +146,92 @@ const createMockConfig = (overrides = {}) => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockSettings = (merged = {}) => ({
|
||||
merged: {
|
||||
hideFooter: false,
|
||||
showMemoryUsage: false,
|
||||
...merged,
|
||||
},
|
||||
});
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const renderComposer = (
|
||||
uiState: UIState,
|
||||
settings = createMockSettings(),
|
||||
config = createMockConfig(),
|
||||
uiActions = createMockUIActions(),
|
||||
) =>
|
||||
render(
|
||||
<ConfigContext.Provider value={config as any}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<Composer />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>
|
||||
<SettingsContext.Provider value={settings as any}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<Composer />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
describe('Composer', () => {
|
||||
describe('Footer Display', () => {
|
||||
it('renders Footer by default', () => {
|
||||
describe('Footer Display Settings', () => {
|
||||
it('renders Footer by default when hideFooter is false', () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({ hideFooter: false });
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
// Smoke check that the Footer renders
|
||||
// Smoke check that the Footer renders when enabled.
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
|
||||
it('does NOT render Footer when hideFooter is true', () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({ hideFooter: true });
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
// Check for content that only appears IN the Footer component itself
|
||||
expect(lastFrame()).not.toContain('[NORMAL]'); // Vim mode indicator
|
||||
expect(lastFrame()).not.toContain('(main'); // Branch name with parentheses
|
||||
});
|
||||
|
||||
it('passes correct props to Footer including vim mode when enabled', async () => {
|
||||
const uiState = createMockUIState({
|
||||
branchName: 'feature-branch',
|
||||
errorCount: 2,
|
||||
sessionStats: {
|
||||
sessionId: 'test-session',
|
||||
sessionStartTime: new Date(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
metrics: {} as any,
|
||||
lastPromptTokenCount: 150,
|
||||
promptCount: 5,
|
||||
},
|
||||
});
|
||||
const config = createMockConfig({
|
||||
getModel: vi.fn(() => 'gemini-1.5-flash'),
|
||||
getTargetDir: vi.fn(() => '/project/path'),
|
||||
getDebugMode: vi.fn(() => true),
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
hideFooter: false,
|
||||
showMemoryUsage: true,
|
||||
});
|
||||
// Mock vim mode for this test
|
||||
const { useVimMode } = await import('../contexts/VimModeContext.js');
|
||||
vi.mocked(useVimMode).mockReturnValueOnce({
|
||||
vimEnabled: true,
|
||||
vimMode: 'INSERT',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings, config);
|
||||
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
// Footer should be rendered with all the state passed through
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading Indicator', () => {
|
||||
@@ -201,7 +261,7 @@ describe('Composer', () => {
|
||||
getAccessibility: vi.fn(() => ({ disableLoadingPhrases: true })),
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, config);
|
||||
const { lastFrame } = renderComposer(uiState, undefined, config);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
@@ -258,8 +318,7 @@ describe('Composer', () => {
|
||||
});
|
||||
|
||||
describe('Context and Status Display', () => {
|
||||
// Note: ContextSummaryDisplay and status prompts are now rendered in Footer, not Composer
|
||||
it('shows empty space in normal state (ContextSummaryDisplay moved to Footer)', () => {
|
||||
it('shows ContextSummaryDisplay in normal state', () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlCPressedOnce: false,
|
||||
ctrlDPressedOnce: false,
|
||||
@@ -268,43 +327,37 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
// ContextSummaryDisplay is now in Footer, so we just verify normal state renders
|
||||
expect(lastFrame()).toBeDefined();
|
||||
expect(lastFrame()).toContain('ContextSummaryDisplay');
|
||||
});
|
||||
|
||||
// Note: Ctrl+C, Ctrl+D, and Escape prompts are now rendered in Footer component
|
||||
// These are tested in Footer.test.tsx
|
||||
it('renders Footer which handles Ctrl+C exit prompt', () => {
|
||||
it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlCPressedOnce: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
// Ctrl+C prompt is now inside Footer, verify Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
|
||||
});
|
||||
|
||||
it('renders Footer which handles Ctrl+D exit prompt', () => {
|
||||
it('shows Ctrl+D exit prompt when ctrlDPressedOnce is true', () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlDPressedOnce: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
// Ctrl+D prompt is now inside Footer, verify Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
|
||||
});
|
||||
|
||||
it('renders Footer which handles escape prompt', () => {
|
||||
it('shows escape prompt when showEscapePrompt is true', () => {
|
||||
const uiState = createMockUIState({
|
||||
showEscapePrompt: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
// Escape prompt is now inside Footer, verify Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
expect(lastFrame()).toContain('Press Esc again to clear');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,9 +382,7 @@ describe('Composer', () => {
|
||||
expect(lastFrame()).not.toContain('InputPrompt');
|
||||
});
|
||||
|
||||
// Note: AutoAcceptIndicator and ShellModeIndicator are now rendered inside Footer component
|
||||
// These are tested in Footer.test.tsx
|
||||
it('renders Footer which contains AutoAcceptIndicator when approval mode is not default', () => {
|
||||
it('shows AutoAcceptIndicator when approval mode is not default and shell mode is inactive', () => {
|
||||
const uiState = createMockUIState({
|
||||
showAutoAcceptIndicator: ApprovalMode.YOLO,
|
||||
shellModeActive: false,
|
||||
@@ -339,19 +390,17 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
// AutoAcceptIndicator is now inside Footer, verify Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
expect(lastFrame()).toContain('AutoAcceptIndicator');
|
||||
});
|
||||
|
||||
it('renders Footer which contains ShellModeIndicator when shell mode is active', () => {
|
||||
it('shows ShellModeIndicator when shell mode is active', () => {
|
||||
const uiState = createMockUIState({
|
||||
shellModeActive: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
// ShellModeIndicator is now inside Footer, verify Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
expect(lastFrame()).toContain('ShellModeIndicator');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,47 +4,42 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useMemo } from 'react';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
||||
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||
import { InputPrompt, calculatePromptWidths } from './InputPrompt.js';
|
||||
import { Footer } from './Footer.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
|
||||
import { KeyboardShortcuts } from './KeyboardShortcuts.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||
import { FeedbackDialog } from '../FeedbackDialog.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const Composer = () => {
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const { vimEnabled } = useVimMode();
|
||||
const terminalWidth = process.stdout.columns;
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
|
||||
|
||||
const { showAutoAcceptIndicator } = uiState;
|
||||
|
||||
// State for keyboard shortcuts display toggle
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
const handleToggleShortcuts = useCallback(() => {
|
||||
setShowShortcuts((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// State for suggestions visibility
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const handleSuggestionsVisibilityChange = useCallback((visible: boolean) => {
|
||||
setShowSuggestions(visible);
|
||||
}, []);
|
||||
const { contextFileNames, showAutoAcceptIndicator } = uiState;
|
||||
|
||||
// Use the container width of InputPrompt for width of DetailedMessagesDisplay
|
||||
const { containerWidth } = useMemo(
|
||||
@@ -53,7 +48,7 @@ export const Composer = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="column">
|
||||
{!uiState.embeddedShellFocused && (
|
||||
<LoadingIndicator
|
||||
thought={
|
||||
@@ -75,6 +70,55 @@ export const Composer = () => {
|
||||
|
||||
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
|
||||
|
||||
<Box
|
||||
marginTop={1}
|
||||
justifyContent={
|
||||
settings.merged.ui?.hideContextSummary
|
||||
? 'flex-start'
|
||||
: 'space-between'
|
||||
}
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box marginRight={1}>
|
||||
{process.env['GEMINI_SYSTEM_MD'] && (
|
||||
<Text color={theme.status.error}>|⌐■_■| </Text>
|
||||
)}
|
||||
{uiState.ctrlCPressedOnce ? (
|
||||
<Text color={theme.status.warning}>
|
||||
{t('Press Ctrl+C again to exit.')}
|
||||
</Text>
|
||||
) : uiState.ctrlDPressedOnce ? (
|
||||
<Text color={theme.status.warning}>
|
||||
{t('Press Ctrl+D again to exit.')}
|
||||
</Text>
|
||||
) : uiState.showEscapePrompt ? (
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Press Esc again to clear.')}
|
||||
</Text>
|
||||
) : (
|
||||
!settings.merged.ui?.hideContextSummary && (
|
||||
<ContextSummaryDisplay
|
||||
ideContext={uiState.ideContextState}
|
||||
geminiMdFileCount={uiState.geminiMdFileCount}
|
||||
contextFileNames={contextFileNames}
|
||||
mcpServers={config.getMcpServers()}
|
||||
blockedMcpServers={config.getBlockedMcpServers()}
|
||||
showToolDescriptions={uiState.showToolDescriptions}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
<Box paddingTop={isNarrow ? 1 : 0}>
|
||||
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
|
||||
!uiState.shellModeActive && (
|
||||
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
|
||||
)}
|
||||
{uiState.shellModeActive && <ShellModeIndicator />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{uiState.showErrorDetails && (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
@@ -90,8 +134,6 @@ export const Composer = () => {
|
||||
</OverflowProvider>
|
||||
)}
|
||||
|
||||
{uiState.isFeedbackDialogOpen && <FeedbackDialog />}
|
||||
|
||||
{uiState.isInputActive && (
|
||||
<InputPrompt
|
||||
buffer={uiState.buffer}
|
||||
@@ -107,9 +149,6 @@ export const Composer = () => {
|
||||
setShellModeActive={uiActions.setShellModeActive}
|
||||
approvalMode={showAutoAcceptIndicator}
|
||||
onEscapePromptChange={uiActions.onEscapePromptChange}
|
||||
onToggleShortcuts={handleToggleShortcuts}
|
||||
showShortcuts={showShortcuts}
|
||||
onSuggestionsVisibilityChange={handleSuggestionsVisibilityChange}
|
||||
focus={true}
|
||||
vimHandleInput={uiActions.vimHandleInput}
|
||||
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
||||
@@ -121,13 +160,7 @@ export const Composer = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Exclusive area: only one component visible at a time */}
|
||||
{!showSuggestions &&
|
||||
(showShortcuts ? (
|
||||
<KeyboardShortcuts />
|
||||
) : (
|
||||
!isScreenReaderEnabled && <Footer />
|
||||
))}
|
||||
{!settings.merged.ui?.hideFooter && !isScreenReaderEnabled && <Footer />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('ConsentPrompt', () => {
|
||||
{
|
||||
isPending: true,
|
||||
text: prompt,
|
||||
contentWidth: terminalWidth,
|
||||
terminalWidth,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
|
||||
<MarkdownDisplay
|
||||
isPending={true}
|
||||
text={prompt}
|
||||
contentWidth={terminalWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
) : (
|
||||
prompt
|
||||
|
||||
@@ -17,19 +17,15 @@ export const ContextUsageDisplay = ({
|
||||
model: string;
|
||||
terminalWidth: number;
|
||||
}) => {
|
||||
if (promptTokenCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const percentage = promptTokenCount / tokenLimit(model);
|
||||
const percentageUsed = (percentage * 100).toFixed(1);
|
||||
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
|
||||
|
||||
const label = terminalWidth < 100 ? '% used' : '% context used';
|
||||
const label = terminalWidth < 100 ? '%' : '% context left';
|
||||
|
||||
return (
|
||||
<Text color={theme.text.secondary}>
|
||||
{percentageUsed}
|
||||
{label}
|
||||
({percentageLeft}
|
||||
{label})
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -152,38 +152,12 @@ export const DialogManager = ({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isEditorDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{uiState.editorError && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.status.error}>{uiState.editorError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<EditorSettingsDialog
|
||||
onSelect={uiActions.handleEditorSelect}
|
||||
settings={settings}
|
||||
onExit={uiActions.exitEditorDialog}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isSettingsDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
onSelect={(settingName) => {
|
||||
if (settingName === 'ui.theme') {
|
||||
uiActions.openThemeDialog();
|
||||
return;
|
||||
}
|
||||
if (settingName === 'general.preferredEditor') {
|
||||
uiActions.openEditorDialog();
|
||||
return;
|
||||
}
|
||||
uiActions.closeSettingsDialog();
|
||||
}}
|
||||
onSelect={() => uiActions.closeSettingsDialog()}
|
||||
onRestartRequest={() => process.exit(0)}
|
||||
availableTerminalHeight={terminalHeight - staticExtraHeight}
|
||||
config={config}
|
||||
@@ -263,6 +237,22 @@ export const DialogManager = ({
|
||||
);
|
||||
}
|
||||
}
|
||||
if (uiState.isEditorDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{uiState.editorError && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.status.error}>{uiState.editorError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<EditorSettingsDialog
|
||||
onSelect={uiActions.handleEditorSelect}
|
||||
settings={settings}
|
||||
onExit={uiActions.exitEditorDialog}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isPermissionsDialogOpen) {
|
||||
return (
|
||||
<PermissionsModifyTrustDialog
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
type EditorDisplay,
|
||||
} from '../editors/editorSettingsManager.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { ScopeSelector } from './shared/ScopeSelector.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import type { EditorType } from '@qwen-code/qwen-code-core';
|
||||
@@ -36,12 +35,13 @@ export function EditorSettingsDialog({
|
||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||
SettingScope.User,
|
||||
);
|
||||
const [mode, setMode] = useState<'editor' | 'scope'>('editor');
|
||||
|
||||
const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>(
|
||||
'editor',
|
||||
);
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'tab') {
|
||||
setMode((prev) => (prev === 'editor' ? 'scope' : 'editor'));
|
||||
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
|
||||
}
|
||||
if (key.name === 'escape') {
|
||||
onExit();
|
||||
@@ -65,6 +65,23 @@ export function EditorSettingsDialog({
|
||||
editorIndex = 0;
|
||||
}
|
||||
|
||||
const scopeItems = [
|
||||
{
|
||||
get label() {
|
||||
return t('User Settings');
|
||||
},
|
||||
value: SettingScope.User,
|
||||
key: SettingScope.User,
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Workspace Settings');
|
||||
},
|
||||
value: SettingScope.Workspace,
|
||||
key: SettingScope.Workspace,
|
||||
},
|
||||
];
|
||||
|
||||
const handleEditorSelect = (editorType: EditorType | 'not_set') => {
|
||||
if (editorType === 'not_set') {
|
||||
onSelect(undefined, selectedScope);
|
||||
@@ -75,11 +92,7 @@ export function EditorSettingsDialog({
|
||||
|
||||
const handleScopeSelect = (scope: SettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
setMode('editor');
|
||||
};
|
||||
|
||||
const handleScopeHighlight = (scope: SettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
setFocusedSection('editor');
|
||||
};
|
||||
|
||||
let otherScopeModifiedMessage = '';
|
||||
@@ -118,59 +131,54 @@ export function EditorSettingsDialog({
|
||||
width="100%"
|
||||
>
|
||||
<Box flexDirection="column" width="45%" paddingRight={2}>
|
||||
{mode === 'editor' ? (
|
||||
<Box flexDirection="column">
|
||||
<Text bold={mode === 'editor'} wrap="truncate">
|
||||
{mode === 'editor' ? '> ' : ' '}
|
||||
{t('Select Editor')}{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
{otherScopeModifiedMessage}
|
||||
</Text>
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<RadioButtonSelect
|
||||
items={editorItems.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.type,
|
||||
disabled: item.disabled,
|
||||
key: item.type,
|
||||
}))}
|
||||
initialIndex={editorIndex}
|
||||
onSelect={handleEditorSelect}
|
||||
isFocused={mode === 'editor'}
|
||||
key={selectedScope}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<ScopeSelector
|
||||
<Text bold={focusedSection === 'editor'}>
|
||||
{focusedSection === 'editor' ? '> ' : ' '}Select Editor{' '}
|
||||
<Text color={theme.text.secondary}>{otherScopeModifiedMessage}</Text>
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={editorItems.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.type,
|
||||
disabled: item.disabled,
|
||||
key: item.type,
|
||||
}))}
|
||||
initialIndex={editorIndex}
|
||||
onSelect={handleEditorSelect}
|
||||
isFocused={focusedSection === 'editor'}
|
||||
key={selectedScope}
|
||||
/>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text bold={focusedSection === 'scope'}>
|
||||
{focusedSection === 'scope' ? '> ' : ' '}
|
||||
{t('Apply To')}
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
initialIndex={0}
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={mode === 'scope'}
|
||||
initialScope={selectedScope}
|
||||
isFocused={focusedSection === 'scope'}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
{mode === 'editor'
|
||||
? t('(Use Enter to select, Tab to configure scope)')
|
||||
: t('(Use Enter to apply scope, Tab to go back)')}
|
||||
<Text color={theme.text.secondary}>
|
||||
(Use Enter to select, Tab to change focus)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" width="55%" paddingLeft={2}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Editor Preference')}
|
||||
Editor Preference
|
||||
</Text>
|
||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t(
|
||||
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.',
|
||||
)}
|
||||
These editors are currently supported. Please note that some editors
|
||||
cannot be used in sandbox mode.
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Your preferred editor is:')}{' '}
|
||||
Your preferred editor is:{' '}
|
||||
<Text
|
||||
color={
|
||||
mergedEditorName === 'None'
|
||||
|
||||
@@ -8,23 +8,41 @@ import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Footer } from './Footer.js';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
import { tildeifyPath } from '@qwen-code/qwen-code-core';
|
||||
import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...original,
|
||||
shortenPath: (p: string, len: number) => {
|
||||
if (p.length > len) {
|
||||
return '...' + p.slice(p.length - len + 3);
|
||||
}
|
||||
return p;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
model: 'gemini-pro',
|
||||
targetDir:
|
||||
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
|
||||
branchName: 'main',
|
||||
};
|
||||
|
||||
const createMockConfig = (overrides = {}) => ({
|
||||
getModel: vi.fn(() => defaultProps.model),
|
||||
getTargetDir: vi.fn(() => defaultProps.targetDir),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getMcpServers: vi.fn(() => ({})),
|
||||
getBlockedMcpServers: vi.fn(() => []),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -33,31 +51,46 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
sessionStats: {
|
||||
lastPromptTokenCount: 100,
|
||||
},
|
||||
geminiMdFileCount: 0,
|
||||
contextFileNames: [],
|
||||
showToolDescriptions: false,
|
||||
ideContextState: undefined,
|
||||
branchName: defaultProps.branchName,
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
const createMockSettings = (): LoadedSettings =>
|
||||
const createDefaultSettings = (
|
||||
options: {
|
||||
showMemoryUsage?: boolean;
|
||||
hideCWD?: boolean;
|
||||
hideSandboxStatus?: boolean;
|
||||
hideModelInfo?: boolean;
|
||||
} = {},
|
||||
): LoadedSettings =>
|
||||
({
|
||||
merged: {
|
||||
general: {
|
||||
vimMode: false,
|
||||
ui: {
|
||||
showMemoryUsage: options.showMemoryUsage,
|
||||
footer: {
|
||||
hideCWD: options.hideCWD,
|
||||
hideSandboxStatus: options.hideSandboxStatus,
|
||||
hideModelInfo: options.hideModelInfo,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as LoadedSettings;
|
||||
}) as never;
|
||||
|
||||
const renderWithWidth = (width: number, uiState: UIState) => {
|
||||
const renderWithWidth = (
|
||||
width: number,
|
||||
uiState: UIState,
|
||||
settings: LoadedSettings = createDefaultSettings(),
|
||||
) => {
|
||||
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
|
||||
return render(
|
||||
<ConfigContext.Provider value={createMockConfig() as never}>
|
||||
<VimModeProvider settings={createMockSettings()}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<Footer />
|
||||
</UIStateContext.Provider>
|
||||
</VimModeProvider>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<VimModeProvider settings={settings}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<Footer />
|
||||
</UIStateContext.Provider>
|
||||
</VimModeProvider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
};
|
||||
@@ -68,28 +101,161 @@ describe('<Footer />', () => {
|
||||
expect(lastFrame()).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not display the working directory or branch name', () => {
|
||||
const { lastFrame } = renderWithWidth(120, createMockUIState());
|
||||
expect(lastFrame()).not.toMatch(/\(.*\*\)/);
|
||||
describe('path display', () => {
|
||||
it('should display a shortened path on a narrow terminal', () => {
|
||||
const { lastFrame } = renderWithWidth(79, createMockUIState());
|
||||
const tildePath = tildeifyPath(defaultProps.targetDir);
|
||||
const pathLength = Math.max(20, Math.floor(79 * 0.25));
|
||||
const expectedPath =
|
||||
'...' + tildePath.slice(tildePath.length - pathLength + 3);
|
||||
expect(lastFrame()).toContain(expectedPath);
|
||||
});
|
||||
|
||||
it('should use wide layout at 80 columns', () => {
|
||||
const { lastFrame } = renderWithWidth(80, createMockUIState());
|
||||
const tildePath = tildeifyPath(defaultProps.targetDir);
|
||||
const expectedPath =
|
||||
'...' + tildePath.slice(tildePath.length - 80 * 0.25 + 3);
|
||||
expect(lastFrame()).toContain(expectedPath);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays the context percentage', () => {
|
||||
it('displays the branch name when provided', () => {
|
||||
const { lastFrame } = renderWithWidth(120, createMockUIState());
|
||||
expect(lastFrame()).toMatch(/\d+(\.\d+)?% context used/);
|
||||
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
|
||||
});
|
||||
|
||||
it('displays the abbreviated context percentage on narrow terminal', () => {
|
||||
it('does not display the branch name when not provided', () => {
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
branchName: undefined,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
|
||||
});
|
||||
|
||||
it('displays the model name and context percentage', () => {
|
||||
const { lastFrame } = renderWithWidth(120, createMockUIState());
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
|
||||
});
|
||||
|
||||
it('displays the model name and abbreviated context percentage', () => {
|
||||
const { lastFrame } = renderWithWidth(99, createMockUIState());
|
||||
expect(lastFrame()).toMatch(/\d+%/);
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\(\d+%\)/);
|
||||
});
|
||||
|
||||
describe('footer rendering (golden snapshots)', () => {
|
||||
it('renders complete footer on wide terminal', () => {
|
||||
describe('sandbox and trust info', () => {
|
||||
it('should display untrusted when isTrustedFolder is false', () => {
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
isTrustedFolder: false,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toContain('untrusted');
|
||||
});
|
||||
|
||||
it('should display custom sandbox info when SANDBOX env is set', () => {
|
||||
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
isTrustedFolder: undefined,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toContain('test');
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', () => {
|
||||
vi.stubEnv('SANDBOX', 'sandbox-exec');
|
||||
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
isTrustedFolder: true,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', () => {
|
||||
// Clear any SANDBOX env var that might be set.
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
isTrustedFolder: true,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toContain('no sandbox');
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should prioritize untrusted message over sandbox info', () => {
|
||||
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
isTrustedFolder: false,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toContain('untrusted');
|
||||
expect(lastFrame()).not.toMatch(/test-sandbox/s);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer configuration filtering (golden snapshots)', () => {
|
||||
it('renders complete footer with all sections visible (baseline)', () => {
|
||||
const { lastFrame } = renderWithWidth(120, createMockUIState());
|
||||
expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
|
||||
});
|
||||
|
||||
it('renders complete footer on narrow terminal', () => {
|
||||
it('renders footer with all optional sections hidden (minimal footer)', () => {
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState(),
|
||||
createDefaultSettings({
|
||||
hideCWD: true,
|
||||
hideSandboxStatus: true,
|
||||
hideModelInfo: true,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('footer-minimal');
|
||||
});
|
||||
|
||||
it('renders footer with only model info hidden (partial filtering)', () => {
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState(),
|
||||
createDefaultSettings({
|
||||
hideCWD: false,
|
||||
hideSandboxStatus: false,
|
||||
hideModelInfo: true,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('footer-no-model');
|
||||
});
|
||||
|
||||
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', () => {
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState(),
|
||||
createDefaultSettings({
|
||||
hideCWD: true,
|
||||
hideSandboxStatus: false,
|
||||
hideModelInfo: true,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('footer-only-sandbox');
|
||||
});
|
||||
|
||||
it('renders complete footer in narrow terminal (baseline narrow)', () => {
|
||||
const { lastFrame } = renderWithWidth(79, createMockUIState());
|
||||
expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
|
||||
});
|
||||
|
||||
@@ -7,134 +7,159 @@
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
|
||||
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
|
||||
import process from 'node:process';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { DebugProfiler } from './DebugProfiler.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const { vimEnabled, vimMode } = useVimMode();
|
||||
|
||||
const {
|
||||
model,
|
||||
targetDir,
|
||||
debugMode,
|
||||
branchName,
|
||||
debugMessage,
|
||||
errorCount,
|
||||
showErrorDetails,
|
||||
promptTokenCount,
|
||||
showAutoAcceptIndicator,
|
||||
nightly,
|
||||
isTrustedFolder,
|
||||
} = {
|
||||
model: config.getModel(),
|
||||
targetDir: config.getTargetDir(),
|
||||
debugMode: config.getDebugMode(),
|
||||
branchName: uiState.branchName,
|
||||
debugMessage: uiState.debugMessage,
|
||||
errorCount: uiState.errorCount,
|
||||
showErrorDetails: uiState.showErrorDetails,
|
||||
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
|
||||
showAutoAcceptIndicator: uiState.showAutoAcceptIndicator,
|
||||
nightly: uiState.nightly,
|
||||
isTrustedFolder: uiState.isTrustedFolder,
|
||||
};
|
||||
|
||||
const showErrorIndicator = !showErrorDetails && errorCount > 0;
|
||||
const showMemoryUsage =
|
||||
config.getDebugMode() || settings.merged.ui?.showMemoryUsage || false;
|
||||
const hideCWD = settings.merged.ui?.footer?.hideCWD || false;
|
||||
const hideSandboxStatus =
|
||||
settings.merged.ui?.footer?.hideSandboxStatus || false;
|
||||
const hideModelInfo = settings.merged.ui?.footer?.hideModelInfo || false;
|
||||
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
|
||||
// Determine sandbox info from environment
|
||||
const sandboxEnv = process.env['SANDBOX'];
|
||||
const sandboxInfo = sandboxEnv
|
||||
? sandboxEnv === 'sandbox-exec'
|
||||
? 'seatbelt'
|
||||
: sandboxEnv.startsWith('qwen-code')
|
||||
? 'docker'
|
||||
: sandboxEnv
|
||||
: null;
|
||||
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.25));
|
||||
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
|
||||
|
||||
// Check if debug mode is enabled
|
||||
const debugMode = config.getDebugMode();
|
||||
|
||||
// Left section should show exactly ONE thing at any time, in priority order.
|
||||
const leftContent = uiState.ctrlCPressedOnce ? (
|
||||
<Text color={theme.status.warning}>{t('Press Ctrl+C again to exit.')}</Text>
|
||||
) : uiState.ctrlDPressedOnce ? (
|
||||
<Text color={theme.status.warning}>{t('Press Ctrl+D again to exit.')}</Text>
|
||||
) : uiState.showEscapePrompt ? (
|
||||
<Text color={theme.text.secondary}>{t('Press Esc again to clear.')}</Text>
|
||||
) : vimEnabled && vimMode === 'INSERT' ? (
|
||||
<Text color={theme.text.secondary}>-- INSERT --</Text>
|
||||
) : uiState.shellModeActive ? (
|
||||
<ShellModeIndicator />
|
||||
) : showAutoAcceptIndicator !== undefined &&
|
||||
showAutoAcceptIndicator !== ApprovalMode.DEFAULT ? (
|
||||
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{t('? for shortcuts')}</Text>
|
||||
);
|
||||
|
||||
const rightItems: Array<{ key: string; node: React.ReactNode }> = [];
|
||||
if (sandboxInfo) {
|
||||
rightItems.push({
|
||||
key: 'sandbox',
|
||||
node: <Text color={theme.status.success}>🔒 {sandboxInfo}</Text>,
|
||||
});
|
||||
}
|
||||
if (debugMode) {
|
||||
rightItems.push({
|
||||
key: 'debug',
|
||||
node: <Text color={theme.status.warning}>Debug Mode</Text>,
|
||||
});
|
||||
}
|
||||
if (promptTokenCount > 0) {
|
||||
rightItems.push({
|
||||
key: 'context',
|
||||
node: (
|
||||
<Text color={theme.text.accent}>
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Text>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showErrorIndicator) {
|
||||
rightItems.push({
|
||||
key: 'errors',
|
||||
node: <ConsoleSummaryDisplay errorCount={errorCount} />,
|
||||
});
|
||||
}
|
||||
const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
|
||||
const displayVimMode = vimEnabled ? vimMode : undefined;
|
||||
|
||||
return (
|
||||
<Box
|
||||
justifyContent="space-between"
|
||||
justifyContent={justifyContent}
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
>
|
||||
{/* Left Section: Exactly one status line (exit prompts / mode indicator / default hint) */}
|
||||
<Box
|
||||
marginLeft={2}
|
||||
justifyContent="flex-start"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{leftContent}
|
||||
</Box>
|
||||
{(debugMode || displayVimMode || !hideCWD) && (
|
||||
<Box>
|
||||
{debugMode && <DebugProfiler />}
|
||||
{displayVimMode && (
|
||||
<Text color={theme.text.secondary}>[{displayVimMode}] </Text>
|
||||
)}
|
||||
{!hideCWD &&
|
||||
(nightly ? (
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>
|
||||
{displayPath}
|
||||
{branchName && <Text> ({branchName}*)</Text>}
|
||||
</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text color={theme.text.link}>
|
||||
{displayPath}
|
||||
{branchName && (
|
||||
<Text color={theme.text.secondary}> ({branchName}*)</Text>
|
||||
)}
|
||||
</Text>
|
||||
))}
|
||||
{debugMode && (
|
||||
<Text color={theme.status.error}>
|
||||
{' ' + (debugMessage || '--debug')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Right Section: Sandbox Info, Debug Mode, Context Usage, and Console Summary */}
|
||||
<Box alignItems="center" justifyContent="flex-end" marginRight={2}>
|
||||
{rightItems.map(({ key, node }, index) => (
|
||||
<Box key={key} alignItems="center">
|
||||
{index > 0 && <Text color={theme.text.secondary}> | </Text>}
|
||||
{node}
|
||||
{/* Middle Section: Centered Trust/Sandbox Info */}
|
||||
{!hideSandboxStatus && (
|
||||
<Box
|
||||
flexGrow={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
display="flex"
|
||||
>
|
||||
{isTrustedFolder === false ? (
|
||||
<Text color={theme.status.warning}>untrusted</Text>
|
||||
) : process.env['SANDBOX'] &&
|
||||
process.env['SANDBOX'] !== 'sandbox-exec' ? (
|
||||
<Text color="green">
|
||||
{process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
|
||||
</Text>
|
||||
) : process.env['SANDBOX'] === 'sandbox-exec' ? (
|
||||
<Text color={theme.status.warning}>
|
||||
macOS Seatbelt{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
({process.env['SEATBELT_PROFILE']})
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.status.error}>
|
||||
no sandbox
|
||||
{terminalWidth >= 100 && (
|
||||
<Text color={theme.text.secondary}> (see /docs)</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Right Section: Gemini Label and Console Summary */}
|
||||
{!hideModelInfo && (
|
||||
<Box alignItems="center" justifyContent="flex-end">
|
||||
<Box alignItems="center">
|
||||
<Text color={theme.text.accent}>
|
||||
{model}{' '}
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Text>
|
||||
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box alignItems="center" paddingLeft={2}>
|
||||
{!showErrorDetails && errorCount > 0 && (
|
||||
<Box>
|
||||
<Text color={theme.ui.symbol}>| </Text>
|
||||
<ConsoleSummaryDisplay errorCount={errorCount} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,135 +6,39 @@
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
AuthType,
|
||||
isGitRepository,
|
||||
getGitBranch,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { Header } from './Header.js';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
import { longAsciiLogo } from './AsciiArt.js';
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||
|
||||
// Mock git functions
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
isGitRepository: vi.fn(),
|
||||
getGitBranch: vi.fn(),
|
||||
}));
|
||||
const mockedIsGitRepository = vi.mocked(isGitRepository);
|
||||
const mockedGetGitBranch = vi.mocked(getGitBranch);
|
||||
|
||||
const defaultProps = {
|
||||
version: '1.0.0',
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
model: 'qwen-coder-plus',
|
||||
workingDirectory: '/home/user/projects/test',
|
||||
};
|
||||
|
||||
describe('<Header />', () => {
|
||||
beforeEach(() => {
|
||||
// Default to wide terminal (shows both logo and info panel)
|
||||
useTerminalSizeMock.mockReturnValue({ columns: 120, rows: 24 });
|
||||
// Default to not in a git repo (no branch shown)
|
||||
mockedIsGitRepository.mockReturnValue(false);
|
||||
mockedGetGitBranch.mockReturnValue(undefined);
|
||||
beforeEach(() => {});
|
||||
|
||||
it('renders the long logo on a wide terminal', () => {
|
||||
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
|
||||
columns: 120,
|
||||
rows: 20,
|
||||
});
|
||||
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
|
||||
expect(lastFrame()).toContain(longAsciiLogo);
|
||||
});
|
||||
|
||||
it('renders the ASCII logo on wide terminal', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
// Check that parts of the shortAsciiLogo are rendered
|
||||
expect(lastFrame()).toContain('██╔═══██╗');
|
||||
});
|
||||
|
||||
it('hides the ASCII logo on narrow terminal', () => {
|
||||
useTerminalSizeMock.mockReturnValue({ columns: 60, rows: 24 });
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
// Should not contain the logo but still show the info panel
|
||||
expect(lastFrame()).not.toContain('██╔═══██╗');
|
||||
expect(lastFrame()).toContain('>_ Qwen Code');
|
||||
});
|
||||
|
||||
it('renders custom ASCII art when provided on wide terminal', () => {
|
||||
it('renders custom ASCII art when provided', () => {
|
||||
const customArt = 'CUSTOM ART';
|
||||
const { lastFrame } = render(
|
||||
<Header {...defaultProps} customAsciiArt={customArt} />,
|
||||
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
|
||||
);
|
||||
expect(lastFrame()).toContain(customArt);
|
||||
});
|
||||
|
||||
it('displays the version number', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
it('displays the version number when nightly is true', () => {
|
||||
const { lastFrame } = render(<Header version="1.0.0" nightly={true} />);
|
||||
expect(lastFrame()).toContain('v1.0.0');
|
||||
});
|
||||
|
||||
it('displays Qwen Code title with >_ prefix', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('>_ Qwen Code');
|
||||
});
|
||||
|
||||
it('displays auth type and model', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('Qwen OAuth');
|
||||
expect(lastFrame()).toContain('qwen-coder-plus');
|
||||
});
|
||||
|
||||
it('displays working directory', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('/home/user/projects/test');
|
||||
});
|
||||
|
||||
it('renders a custom working directory display', () => {
|
||||
const { lastFrame } = render(
|
||||
<Header {...defaultProps} workingDirectory="custom display" />,
|
||||
);
|
||||
expect(lastFrame()).toContain('custom display');
|
||||
});
|
||||
|
||||
it('displays working directory without branch name', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
// Branch name is no longer shown in header
|
||||
expect(lastFrame()).toContain('/home/user/projects/test');
|
||||
expect(lastFrame()).not.toContain('(main*)');
|
||||
});
|
||||
|
||||
it('displays git branch when in a git repository', () => {
|
||||
mockedIsGitRepository.mockReturnValue(true);
|
||||
mockedGetGitBranch.mockReturnValue('main');
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('(main)');
|
||||
});
|
||||
|
||||
it('does not display git branch when not in a git repository', () => {
|
||||
mockedIsGitRepository.mockReturnValue(false);
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
// Check that there's no git branch line (e.g., "(main)")
|
||||
// Branch is shown after the working directory path, not as part of title
|
||||
expect(lastFrame()).not.toContain('(main)');
|
||||
expect(lastFrame()).not.toMatch(/\([^)]+\)$/m);
|
||||
});
|
||||
|
||||
it('displays different git branch names', () => {
|
||||
mockedIsGitRepository.mockReturnValue(true);
|
||||
mockedGetGitBranch.mockReturnValue('feature/new-feature');
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('(feature/new-feature)');
|
||||
});
|
||||
|
||||
it('formats home directory with tilde', () => {
|
||||
const { lastFrame } = render(
|
||||
<Header {...defaultProps} workingDirectory="/Users/testuser/projects" />,
|
||||
);
|
||||
// The actual home dir replacement depends on os.homedir()
|
||||
// Just verify the path is shown
|
||||
expect(lastFrame()).toContain('projects');
|
||||
});
|
||||
|
||||
it('renders with border around info panel', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
// Check for border characters (round border style uses these)
|
||||
expect(lastFrame()).toContain('╭');
|
||||
expect(lastFrame()).toContain('╯');
|
||||
it('does not display the version number when nightly is false', () => {
|
||||
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
|
||||
expect(lastFrame()).not.toContain('v1.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,188 +7,64 @@
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Gradient from 'ink-gradient';
|
||||
import {
|
||||
AuthType,
|
||||
isGitRepository,
|
||||
getGitBranch,
|
||||
shortenPath,
|
||||
tildeifyPath,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { shortAsciiLogo } from './AsciiArt.js';
|
||||
import { getAsciiArtWidth, getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
|
||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
|
||||
interface HeaderProps {
|
||||
customAsciiArt?: string; // For user-defined ASCII art
|
||||
version: string;
|
||||
authType?: AuthType;
|
||||
model: string;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
function titleizeAuthType(value: string): string {
|
||||
return value
|
||||
.split(/[-_]/g)
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
if (part.toLowerCase() === 'ai') {
|
||||
return 'AI';
|
||||
}
|
||||
return part.charAt(0).toUpperCase() + part.slice(1);
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// Format auth type for display
|
||||
function formatAuthType(authType?: AuthType): string {
|
||||
if (!authType) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
switch (authType) {
|
||||
case AuthType.QWEN_OAUTH:
|
||||
return 'Qwen OAuth';
|
||||
case AuthType.USE_OPENAI:
|
||||
return 'OpenAI';
|
||||
case AuthType.USE_GEMINI:
|
||||
return 'Gemini';
|
||||
case AuthType.USE_VERTEX_AI:
|
||||
return 'Vertex AI';
|
||||
case AuthType.USE_ANTHROPIC:
|
||||
return 'Anthropic';
|
||||
default:
|
||||
return titleizeAuthType(String(authType));
|
||||
}
|
||||
nightly: boolean;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
customAsciiArt,
|
||||
version,
|
||||
authType,
|
||||
model,
|
||||
workingDirectory,
|
||||
nightly,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
let displayTitle;
|
||||
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
|
||||
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
|
||||
|
||||
const displayLogo = customAsciiArt ?? shortAsciiLogo;
|
||||
const logoWidth = getAsciiArtWidth(displayLogo);
|
||||
const formattedAuthType = formatAuthType(authType);
|
||||
if (customAsciiArt) {
|
||||
displayTitle = customAsciiArt;
|
||||
} else if (terminalWidth >= widthOfLongLogo) {
|
||||
displayTitle = longAsciiLogo;
|
||||
} else if (terminalWidth >= widthOfShortLogo) {
|
||||
displayTitle = shortAsciiLogo;
|
||||
} else {
|
||||
displayTitle = tinyAsciiLogo;
|
||||
}
|
||||
|
||||
// Calculate available space properly:
|
||||
// First determine if logo can be shown, then use remaining space for path
|
||||
const containerMarginX = 2; // marginLeft + marginRight on the outer container
|
||||
const logoGap = 2; // Gap between logo and info panel
|
||||
const infoPanelPaddingX = 1;
|
||||
const infoPanelBorderWidth = 2; // left + right border
|
||||
const infoPanelChromeWidth = infoPanelBorderWidth + infoPanelPaddingX * 2;
|
||||
const minPathLength = 40; // Minimum readable path length
|
||||
const minInfoPanelWidth = minPathLength + infoPanelChromeWidth;
|
||||
|
||||
const availableTerminalWidth = Math.max(
|
||||
0,
|
||||
terminalWidth - containerMarginX * 2,
|
||||
);
|
||||
|
||||
// Check if we have enough space for logo + gap + minimum info panel
|
||||
const showLogo =
|
||||
availableTerminalWidth >= logoWidth + logoGap + minInfoPanelWidth;
|
||||
|
||||
// Calculate available width for info panel (use all remaining space)
|
||||
// Cap at 60 when in two-column layout (with logo)
|
||||
const maxInfoPanelWidth = 60;
|
||||
const availableInfoPanelWidth = showLogo
|
||||
? Math.min(availableTerminalWidth - logoWidth - logoGap, maxInfoPanelWidth)
|
||||
: availableTerminalWidth;
|
||||
|
||||
// Calculate max path length (subtract padding/borders from available space)
|
||||
const maxPathLength = Math.max(
|
||||
0,
|
||||
availableInfoPanelWidth - infoPanelChromeWidth,
|
||||
);
|
||||
|
||||
const infoPanelContentWidth = Math.max(
|
||||
0,
|
||||
availableInfoPanelWidth - infoPanelChromeWidth,
|
||||
);
|
||||
const authModelText = `${formattedAuthType} | ${model}`;
|
||||
const authHintText = ' (/auth to change)';
|
||||
const showAuthHint =
|
||||
infoPanelContentWidth > 0 &&
|
||||
getCachedStringWidth(authModelText + authHintText) <= infoPanelContentWidth;
|
||||
|
||||
// Now shorten the path to fit the available space
|
||||
const tildeifiedPath = tildeifyPath(workingDirectory);
|
||||
const shortenedPath = shortenPath(tildeifiedPath, Math.max(3, maxPathLength));
|
||||
const displayPath =
|
||||
maxPathLength <= 0
|
||||
? ''
|
||||
: shortenedPath.length > maxPathLength
|
||||
? shortenedPath.slice(0, maxPathLength)
|
||||
: shortenedPath;
|
||||
|
||||
// Get git branch if cwd is in a legitimate git repo
|
||||
const gitBranch = isGitRepository(workingDirectory)
|
||||
? getGitBranch(workingDirectory)
|
||||
: undefined;
|
||||
|
||||
// Use theme gradient colors if available, otherwise use text colors (excluding primary)
|
||||
const gradientColors = theme.ui.gradient || [
|
||||
theme.text.secondary,
|
||||
theme.text.link,
|
||||
theme.text.accent,
|
||||
];
|
||||
const artWidth = getAsciiArtWidth(displayTitle);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
marginX={containerMarginX}
|
||||
width={availableTerminalWidth}
|
||||
alignItems="flex-start"
|
||||
width={artWidth}
|
||||
flexShrink={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{/* Left side: ASCII logo (only if enough space) */}
|
||||
{showLogo && (
|
||||
<>
|
||||
<Box flexShrink={0}>
|
||||
<Gradient colors={gradientColors}>
|
||||
<Text>{displayLogo}</Text>
|
||||
</Gradient>
|
||||
</Box>
|
||||
{/* Fixed gap between logo and info panel */}
|
||||
<Box width={logoGap} />
|
||||
</>
|
||||
{theme.ui.gradient ? (
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>{displayTitle}</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text>{displayTitle}</Text>
|
||||
)}
|
||||
|
||||
{/* Right side: Info panel (flexible width, max 60 in two-column layout) */}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={infoPanelPaddingX}
|
||||
flexGrow={showLogo ? 0 : 1}
|
||||
width={showLogo ? availableInfoPanelWidth : undefined}
|
||||
>
|
||||
{/* Title line: >_ Qwen Code (v{version}) */}
|
||||
<Text>
|
||||
<Text bold color={theme.text.accent}>
|
||||
>_ Qwen Code
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> (v{version})</Text>
|
||||
</Text>
|
||||
{/* Empty line for spacing */}
|
||||
<Text> </Text>
|
||||
{/* Auth and Model line */}
|
||||
<Text>
|
||||
<Text color={theme.text.secondary}>{authModelText}</Text>
|
||||
{showAuthHint && (
|
||||
<Text color={theme.text.secondary}>{authHintText}</Text>
|
||||
{nightly && (
|
||||
<Box width="100%" flexDirection="row" justifyContent="flex-end">
|
||||
{theme.ui.gradient ? (
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>v{version}</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text>v{version}</Text>
|
||||
)}
|
||||
</Text>
|
||||
{/* Directory line */}
|
||||
<Text color={theme.text.secondary}>{displayPath}</Text>
|
||||
{/* Git branch line (only if in a git repo) */}
|
||||
{gitBranch && <Text color={theme.text.secondary}> ({gitBranch})</Text>}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,16 +12,15 @@ import { t } from '../../i18n/index.js';
|
||||
|
||||
interface Help {
|
||||
commands: readonly SlashCommand[];
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const Help: React.FC<Help> = ({ commands, width }) => (
|
||||
export const Help: React.FC<Help> = ({ commands }) => (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginBottom={1}
|
||||
borderColor={theme.border.default}
|
||||
borderStyle="round"
|
||||
padding={1}
|
||||
width={width}
|
||||
>
|
||||
{/* Basics */}
|
||||
<Text bold color={theme.text.primary}>
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('<HistoryItemDisplay />', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay {...baseItem} item={item} />,
|
||||
);
|
||||
expect(lastFrame()).toContain('Status');
|
||||
expect(lastFrame()).toContain('About Qwen Code');
|
||||
});
|
||||
|
||||
it('renders ModelStatsDisplay for "model_stats" type', () => {
|
||||
|
||||
@@ -38,7 +38,6 @@ interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
mainAreaWidth?: number;
|
||||
isPending: boolean;
|
||||
isFocused?: boolean;
|
||||
commands?: readonly SlashCommand[];
|
||||
@@ -51,7 +50,6 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
item,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
mainAreaWidth,
|
||||
isPending,
|
||||
commands,
|
||||
isFocused = true,
|
||||
@@ -60,16 +58,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
availableTerminalHeightGemini,
|
||||
}) => {
|
||||
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
|
||||
const contentWidth = terminalWidth - 4;
|
||||
const boxWidth = mainAreaWidth || contentWidth;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
key={itemForDisplay.id}
|
||||
marginLeft={2}
|
||||
marginRight={2}
|
||||
>
|
||||
<Box flexDirection="column" key={itemForDisplay.id}>
|
||||
{/* Render standard message types */}
|
||||
{itemForDisplay.type === 'user' && (
|
||||
<UserMessage text={itemForDisplay.text} />
|
||||
@@ -84,7 +75,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
contentWidth={contentWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_content' && (
|
||||
@@ -94,7 +85,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
contentWidth={contentWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_thought' && (
|
||||
@@ -104,7 +95,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
contentWidth={contentWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_thought_content' && (
|
||||
@@ -114,7 +105,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
contentWidth={contentWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'info' && (
|
||||
@@ -127,32 +118,25 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
<ErrorMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
{itemForDisplay.type === 'about' && (
|
||||
<AboutBox {...itemForDisplay.systemInfo} width={boxWidth} />
|
||||
<AboutBox {...itemForDisplay.systemInfo} />
|
||||
)}
|
||||
{itemForDisplay.type === 'help' && commands && (
|
||||
<Help commands={commands} width={boxWidth} />
|
||||
<Help commands={commands} />
|
||||
)}
|
||||
{itemForDisplay.type === 'stats' && (
|
||||
<StatsDisplay duration={itemForDisplay.duration} width={boxWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'model_stats' && (
|
||||
<ModelStatsDisplay width={boxWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'tool_stats' && (
|
||||
<ToolStatsDisplay width={boxWidth} />
|
||||
<StatsDisplay duration={itemForDisplay.duration} />
|
||||
)}
|
||||
{itemForDisplay.type === 'model_stats' && <ModelStatsDisplay />}
|
||||
{itemForDisplay.type === 'tool_stats' && <ToolStatsDisplay />}
|
||||
{itemForDisplay.type === 'quit' && (
|
||||
<SessionSummaryDisplay
|
||||
duration={itemForDisplay.duration}
|
||||
width={boxWidth}
|
||||
/>
|
||||
<SessionSummaryDisplay duration={itemForDisplay.duration} />
|
||||
)}
|
||||
{itemForDisplay.type === 'tool_group' && (
|
||||
<ToolGroupMessage
|
||||
toolCalls={itemForDisplay.tools}
|
||||
groupId={itemForDisplay.id}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
contentWidth={contentWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
isFocused={isFocused}
|
||||
activeShellPtyId={activeShellPtyId}
|
||||
embeddedShellFocused={embeddedShellFocused}
|
||||
@@ -165,7 +149,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
{itemForDisplay.type === 'extensions_list' && <ExtensionsList />}
|
||||
{itemForDisplay.type === 'tools_list' && (
|
||||
<ToolsList
|
||||
contentWidth={contentWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
tools={itemForDisplay.tools}
|
||||
showDescriptions={itemForDisplay.showDescriptions}
|
||||
/>
|
||||
|
||||
@@ -33,9 +33,6 @@ vi.mock('../hooks/useCommandCompletion.js');
|
||||
vi.mock('../hooks/useInputHistory.js');
|
||||
vi.mock('../hooks/useReverseSearchCompletion.js');
|
||||
vi.mock('../utils/clipboardUtils.js');
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false })),
|
||||
}));
|
||||
|
||||
const mockSlashCommands: SlashCommand[] = [
|
||||
{
|
||||
@@ -281,7 +278,7 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call completion.navigateUp for up arrow when suggestions are showing', async () => {
|
||||
it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
@@ -296,22 +293,19 @@ describe('InputPrompt', () => {
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Test up arrow for completion navigation
|
||||
// Test up arrow
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
await wait();
|
||||
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
|
||||
|
||||
// Ctrl+P should navigate history, not completion
|
||||
stdin.write('\u0010'); // Ctrl+P
|
||||
await wait();
|
||||
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1);
|
||||
expect(mockInputHistory.navigateUp).toHaveBeenCalled();
|
||||
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(2);
|
||||
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call completion.navigateDown for down arrow when suggestions are showing', async () => {
|
||||
it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
@@ -325,17 +319,14 @@ describe('InputPrompt', () => {
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Test down arrow for completion navigation
|
||||
// Test down arrow
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait();
|
||||
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
|
||||
|
||||
// Ctrl+N should navigate history, not completion
|
||||
stdin.write('\u000E'); // Ctrl+N
|
||||
await wait();
|
||||
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(1);
|
||||
expect(mockInputHistory.navigateDown).toHaveBeenCalled();
|
||||
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(2);
|
||||
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -773,8 +764,6 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -802,8 +791,6 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -831,8 +818,6 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -860,8 +845,6 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -889,8 +872,6 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -919,8 +900,6 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -948,8 +927,6 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -978,8 +955,6 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1008,8 +983,6 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1038,8 +1011,6 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1068,8 +1039,6 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1100,8 +1069,6 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1130,8 +1097,6 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1162,8 +1127,6 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
@@ -36,8 +36,6 @@ import {
|
||||
import * as path from 'node:path';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||||
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
|
||||
export interface InputPromptProps {
|
||||
buffer: TextBuffer;
|
||||
onSubmit: (value: string) => void;
|
||||
@@ -54,9 +52,6 @@ export interface InputPromptProps {
|
||||
setShellModeActive: (value: boolean) => void;
|
||||
approvalMode: ApprovalMode;
|
||||
onEscapePromptChange?: (showPrompt: boolean) => void;
|
||||
onToggleShortcuts?: () => void;
|
||||
showShortcuts?: boolean;
|
||||
onSuggestionsVisibilityChange?: (visible: boolean) => void;
|
||||
vimHandleInput?: (key: Key) => boolean;
|
||||
isEmbeddedShellFocused?: boolean;
|
||||
}
|
||||
@@ -101,14 +96,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setShellModeActive,
|
||||
approvalMode,
|
||||
onEscapePromptChange,
|
||||
onToggleShortcuts,
|
||||
showShortcuts,
|
||||
onSuggestionsVisibilityChange,
|
||||
vimHandleInput,
|
||||
isEmbeddedShellFocused,
|
||||
}) => {
|
||||
const isShellFocused = useShellFocusState();
|
||||
const uiState = useUIState();
|
||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||
const [escPressCount, setEscPressCount] = useState(0);
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
@@ -144,8 +135,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
commandContext,
|
||||
reverseSearchActive,
|
||||
config,
|
||||
// Suppress completion when history navigation just occurred
|
||||
!justNavigatedHistory,
|
||||
);
|
||||
|
||||
const reverseSearchCompletion = useReverseSearchCompletion(
|
||||
@@ -230,9 +219,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const inputHistory = useInputHistory({
|
||||
userMessages,
|
||||
onSubmit: handleSubmitAndClear,
|
||||
// History navigation (Ctrl+P/N) now always works since completion navigation
|
||||
// only uses arrow keys. Only disable in shell mode.
|
||||
isActive: !shellModeActive,
|
||||
isActive:
|
||||
(!completion.showSuggestions || completion.suggestions.length === 1) &&
|
||||
!shellModeActive,
|
||||
currentQuery: buffer.text,
|
||||
onChange: customSetTextAndResetCompletionSignal,
|
||||
});
|
||||
@@ -337,14 +326,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept feedback dialog option keys (1, 2) when dialog is open
|
||||
if (
|
||||
uiState.isFeedbackDialogOpen &&
|
||||
(FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset ESC count and hide prompt on any non-ESC key
|
||||
if (key.name !== 'escape') {
|
||||
if (escPressCount > 0 || showEscapePrompt) {
|
||||
@@ -357,31 +338,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.text === '' &&
|
||||
!completion.showSuggestions
|
||||
) {
|
||||
// Hide shortcuts when toggling shell mode
|
||||
if (showShortcuts && onToggleShortcuts) {
|
||||
onToggleShortcuts();
|
||||
}
|
||||
setShellModeActive(!shellModeActive);
|
||||
buffer.setText(''); // Clear the '!' from input
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle keyboard shortcuts display with "?" when buffer is empty
|
||||
if (
|
||||
key.sequence === '?' &&
|
||||
buffer.text === '' &&
|
||||
!completion.showSuggestions &&
|
||||
onToggleShortcuts
|
||||
) {
|
||||
onToggleShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide shortcuts on any other key press
|
||||
if (showShortcuts && onToggleShortcuts) {
|
||||
onToggleShortcuts();
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
const cancelSearch = (
|
||||
setActive: (active: boolean) => void,
|
||||
@@ -709,9 +670,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
recentPasteTime,
|
||||
commandSearchActive,
|
||||
commandSearchCompletion,
|
||||
onToggleShortcuts,
|
||||
showShortcuts,
|
||||
uiState,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -731,13 +689,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const activeCompletion = getActiveCompletion();
|
||||
const shouldShowSuggestions = activeCompletion.showSuggestions;
|
||||
|
||||
// Notify parent about suggestions visibility changes
|
||||
useEffect(() => {
|
||||
if (onSuggestionsVisibilityChange) {
|
||||
onSuggestionsVisibilityChange(shouldShowSuggestions);
|
||||
}
|
||||
}, [shouldShowSuggestions, onSuggestionsVisibilityChange]);
|
||||
|
||||
const showAutoAcceptStyling =
|
||||
!shellModeActive && approvalMode === ApprovalMode.AUTO_EDIT;
|
||||
const showYoloStyling =
|
||||
@@ -770,6 +721,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text
|
||||
color={statusColor ?? theme.text.accent}
|
||||
@@ -900,7 +852,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
{shouldShowSuggestions && (
|
||||
<Box marginLeft={2} marginRight={2}>
|
||||
<Box paddingRight={2}>
|
||||
<SuggestionsDisplay
|
||||
suggestions={activeCompletion.suggestions}
|
||||
activeIndex={activeCompletion.activeSuggestionIndex}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface Shortcut {
|
||||
key: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Platform-specific key mappings
|
||||
const getNewlineKey = () =>
|
||||
process.platform === 'win32' ? 'ctrl+enter' : 'ctrl+j';
|
||||
const getPasteKey = () => (process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v');
|
||||
const getExternalEditorKey = () =>
|
||||
process.platform === 'darwin' ? 'ctrl+x' : 'ctrl+x';
|
||||
|
||||
// Generate shortcuts with translations (called at render time)
|
||||
const getShortcuts = (): Shortcut[] => [
|
||||
{ key: '!', description: t('for shell mode') },
|
||||
{ key: '/', description: t('for commands') },
|
||||
{ key: '@', description: t('for file paths') },
|
||||
{ key: 'esc esc', description: t('to clear input') },
|
||||
{ key: 'shift+tab', description: t('to cycle approvals') },
|
||||
{ key: 'ctrl+c', description: t('to quit') },
|
||||
{ key: getNewlineKey(), description: t('for newline') + ' ⏎' },
|
||||
{ key: 'ctrl+l', description: t('to clear screen') },
|
||||
{ key: 'ctrl+r', description: t('to search history') },
|
||||
{ key: getPasteKey(), description: t('to paste images') },
|
||||
{ key: getExternalEditorKey(), description: t('for external editor') },
|
||||
];
|
||||
|
||||
const ShortcutItem: React.FC<{ shortcut: Shortcut }> = ({ shortcut }) => (
|
||||
<Text color={theme.text.secondary}>
|
||||
<Text color={theme.text.primary}>{shortcut.key}</Text>{' '}
|
||||
{shortcut.description}
|
||||
</Text>
|
||||
);
|
||||
|
||||
// Layout constants
|
||||
const COLUMN_GAP = 4;
|
||||
const MARGIN_LEFT = 2;
|
||||
const MARGIN_RIGHT = 2;
|
||||
|
||||
// Column distribution for different layouts (3+4+4 for 3 cols, 6+5 for 2 cols)
|
||||
const COLUMN_SPLITS: Record<number, number[]> = {
|
||||
3: [3, 4, 4],
|
||||
2: [6, 5],
|
||||
1: [11],
|
||||
};
|
||||
|
||||
export const KeyboardShortcuts: React.FC = () => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const shortcuts = getShortcuts();
|
||||
|
||||
// Helper to calculate width needed for a column layout
|
||||
const getShortcutWidth = (shortcut: Shortcut) =>
|
||||
shortcut.key.length + 1 + shortcut.description.length;
|
||||
|
||||
const calculateLayoutWidth = (splits: number[]): number => {
|
||||
let startIndex = 0;
|
||||
let totalWidth = 0;
|
||||
splits.forEach((count, colIndex) => {
|
||||
const columnItems = shortcuts.slice(startIndex, startIndex + count);
|
||||
const columnWidth = Math.max(...columnItems.map(getShortcutWidth));
|
||||
totalWidth += columnWidth;
|
||||
if (colIndex < splits.length - 1) {
|
||||
totalWidth += COLUMN_GAP;
|
||||
}
|
||||
startIndex += count;
|
||||
});
|
||||
return totalWidth;
|
||||
};
|
||||
|
||||
// Calculate number of columns based on terminal width and actual content
|
||||
const availableWidth = terminalWidth - MARGIN_LEFT - MARGIN_RIGHT;
|
||||
const width3Col = calculateLayoutWidth(COLUMN_SPLITS[3]);
|
||||
const width2Col = calculateLayoutWidth(COLUMN_SPLITS[2]);
|
||||
|
||||
const numColumns =
|
||||
availableWidth >= width3Col ? 3 : availableWidth >= width2Col ? 2 : 1;
|
||||
|
||||
// Split shortcuts into columns using predefined distribution
|
||||
const splits = COLUMN_SPLITS[numColumns];
|
||||
const columns: Shortcut[][] = [];
|
||||
let startIndex = 0;
|
||||
for (const count of splits) {
|
||||
columns.push(shortcuts.slice(startIndex, startIndex + count));
|
||||
startIndex += count;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
marginLeft={MARGIN_LEFT}
|
||||
marginRight={MARGIN_RIGHT}
|
||||
>
|
||||
{columns.map((column, colIndex) => (
|
||||
<Box
|
||||
key={colIndex}
|
||||
flexDirection="column"
|
||||
marginRight={colIndex < numColumns - 1 ? COLUMN_GAP : 0}
|
||||
>
|
||||
{column.map((shortcut) => (
|
||||
<ShortcutItem key={shortcut.key} shortcut={shortcut} />
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -23,7 +23,6 @@ export const MainContent = () => {
|
||||
const uiState = useUIState();
|
||||
const {
|
||||
pendingHistoryItems,
|
||||
terminalWidth,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
availableTerminalHeight,
|
||||
@@ -37,8 +36,7 @@ export const MainContent = () => {
|
||||
<AppHeader key="app-header" version={version} />,
|
||||
...uiState.history.map((h) => (
|
||||
<HistoryItemDisplay
|
||||
terminalWidth={terminalWidth}
|
||||
mainAreaWidth={mainAreaWidth}
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={staticAreaMaxItemHeight}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={h.id}
|
||||
@@ -59,8 +57,7 @@ export const MainContent = () => {
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
mainAreaWidth={mainAreaWidth}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
|
||||
@@ -275,7 +275,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
persistModelSelection(settings, effectiveModelId);
|
||||
persistAuthTypeSelection(settings, effectiveAuthType);
|
||||
|
||||
const baseUrl = after?.baseUrl ?? t('(default)');
|
||||
const baseUrl = after?.baseUrl ?? '(default)';
|
||||
const maskedKey = maskApiKey(after?.apiKey);
|
||||
uiState?.historyManager.addItem(
|
||||
{
|
||||
@@ -322,7 +322,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
<>
|
||||
<ConfigRow
|
||||
label="Base URL"
|
||||
value={effectiveConfig?.baseUrl ?? t('(default)')}
|
||||
value={effectiveConfig?.baseUrl ?? ''}
|
||||
badge={formatSourceBadge(sources['baseUrl'])}
|
||||
/>
|
||||
<ConfigRow
|
||||
|
||||
@@ -50,13 +50,7 @@ const StatRow: React.FC<StatRowProps> = ({
|
||||
</Box>
|
||||
);
|
||||
|
||||
interface ModelStatsDisplayProps {
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
|
||||
width,
|
||||
}) => {
|
||||
export const ModelStatsDisplay: React.FC = () => {
|
||||
const { stats } = useSessionStats();
|
||||
const { models } = stats.metrics;
|
||||
const activeModels = Object.entries(models).filter(
|
||||
@@ -70,7 +64,6 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
|
||||
borderColor={theme.border.default}
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={width}
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
{t('No API calls have been made in this session.')}
|
||||
@@ -100,7 +93,6 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={width}
|
||||
>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{t('Model Stats For Nerds')}
|
||||
|
||||
@@ -34,7 +34,7 @@ export const PlanSummaryDisplay: React.FC<PlanSummaryDisplayProps> = ({
|
||||
text={plan}
|
||||
isPending={false}
|
||||
availableTerminalHeight={availableHeight}
|
||||
contentWidth={childWidth}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@ export const QuittingDisplay = () => {
|
||||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
const availableTerminalHeight = terminalHeight;
|
||||
const { mainAreaWidth } = uiState;
|
||||
|
||||
if (!uiState.quittingMessages) {
|
||||
return null;
|
||||
@@ -29,7 +28,6 @@ export const QuittingDisplay = () => {
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
mainAreaWidth={mainAreaWidth}
|
||||
item={item}
|
||||
isPending={false}
|
||||
/>
|
||||
|
||||
@@ -127,8 +127,8 @@ export function SessionPicker(props: SessionPickerProps) {
|
||||
|
||||
const { columns: width, rows: height } = useTerminalSize();
|
||||
|
||||
// Calculate box width (marginX={2})
|
||||
const boxWidth = width - 4;
|
||||
// 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;
|
||||
@@ -179,7 +179,7 @@ export function SessionPicker(props: SessionPickerProps) {
|
||||
|
||||
{/* Separator */}
|
||||
<Box>
|
||||
<Text color={theme.border.default}>{'─'.repeat(boxWidth - 2)}</Text>
|
||||
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Session list */}
|
||||
@@ -212,7 +212,7 @@ export function SessionPicker(props: SessionPickerProps) {
|
||||
isLast={visibleIndex === picker.visibleSessions.length - 1}
|
||||
showScrollUp={picker.showScrollUp}
|
||||
showScrollDown={picker.showScrollDown}
|
||||
maxPromptWidth={boxWidth - 6}
|
||||
maxPromptWidth={width}
|
||||
prefixChars={PREFIX_CHARS}
|
||||
boldSelectedPrefix={false}
|
||||
/>
|
||||
@@ -223,7 +223,7 @@ export function SessionPicker(props: SessionPickerProps) {
|
||||
|
||||
{/* Separator */}
|
||||
<Box>
|
||||
<Text color={theme.border.default}>{'─'.repeat(boxWidth - 2)}</Text>
|
||||
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -14,12 +14,10 @@ import { t } from '../../i18n/index.js';
|
||||
|
||||
interface SessionSummaryDisplayProps {
|
||||
duration: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||
duration,
|
||||
width,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
const { stats } = useSessionStats();
|
||||
@@ -34,7 +32,6 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||
<StatsDisplay
|
||||
title={t('Agent powering down. Goodbye!')}
|
||||
duration={duration}
|
||||
width={width}
|
||||
/>
|
||||
{hasMessages && canResume && (
|
||||
<Box marginTop={1}>
|
||||
|
||||
@@ -28,13 +28,12 @@ import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import { act } from 'react';
|
||||
import { saveModifiedSettings, TEST_ONLY } from '../../utils/settingsUtils.js';
|
||||
import {
|
||||
getDialogSettingKeys,
|
||||
getSettingDefinition,
|
||||
saveModifiedSettings,
|
||||
TEST_ONLY,
|
||||
} from '../../utils/settingsUtils.js';
|
||||
import { OUTPUT_LANGUAGE_AUTO } from '../../utils/languageUtils.js';
|
||||
getSettingsSchema,
|
||||
type SettingDefinition,
|
||||
type SettingsSchemaType,
|
||||
} from '../../config/settingsSchema.js';
|
||||
|
||||
// Mock the VimModeContext
|
||||
const mockToggleVimEnabled = vi.fn();
|
||||
@@ -211,9 +210,8 @@ describe('SettingsDialog', () => {
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Settings');
|
||||
// Scope selector is now in a separate view (Tab to switch)
|
||||
expect(output).not.toContain('Apply To');
|
||||
expect(output).toContain('(Use Enter to select, Tab to configure scope)');
|
||||
expect(output).toContain('Apply To');
|
||||
expect(output).toContain('Use Enter to select, Tab to change focus');
|
||||
});
|
||||
|
||||
it('should accept availableTerminalHeight prop without errors', () => {
|
||||
@@ -233,7 +231,7 @@ describe('SettingsDialog', () => {
|
||||
const output = lastFrame();
|
||||
// Should still render properly with the height prop
|
||||
expect(output).toContain('Settings');
|
||||
expect(output).toContain('Enter to select');
|
||||
expect(output).toContain('Use Enter to select');
|
||||
});
|
||||
|
||||
it('should show settings list with default values', () => {
|
||||
@@ -283,12 +281,7 @@ describe('SettingsDialog', () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down arrow
|
||||
});
|
||||
|
||||
const secondKey = getDialogSettingKeys()[1];
|
||||
expect(secondKey).toBeDefined();
|
||||
const secondLabel = secondKey
|
||||
? (getSettingDefinition(secondKey)?.label ?? secondKey)
|
||||
: '';
|
||||
expect(lastFrame()).toContain(`● ${secondLabel}`);
|
||||
expect(lastFrame()).toContain('● Disable Auto Update');
|
||||
|
||||
// The active index should have changed (tested indirectly through behavior)
|
||||
unmount();
|
||||
@@ -349,14 +342,7 @@ describe('SettingsDialog', () => {
|
||||
|
||||
await wait();
|
||||
|
||||
const lastKey = getDialogSettingKeys().at(-1);
|
||||
expect(lastKey).toBeDefined();
|
||||
|
||||
const lastLabel = lastKey
|
||||
? (getSettingDefinition(lastKey)?.label ?? lastKey)
|
||||
: '';
|
||||
|
||||
expect(lastFrame()).toContain(`● ${lastLabel}`);
|
||||
expect(lastFrame()).toContain('● Vision Model Preview');
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -376,26 +362,19 @@ describe('SettingsDialog', () => {
|
||||
|
||||
const { stdin, unmount, lastFrame } = render(component);
|
||||
|
||||
// Wait for initial render and verify we're on Tool Approval Mode (first setting)
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('● Tool Approval Mode');
|
||||
});
|
||||
|
||||
const dialogKeys = getDialogSettingKeys();
|
||||
const targetIndex = dialogKeys.indexOf('general.vimMode');
|
||||
expect(targetIndex).toBeGreaterThan(0);
|
||||
|
||||
// Navigate to Vim Mode setting and verify we're there
|
||||
for (let i = 0; i < targetIndex; i++) {
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
});
|
||||
await wait();
|
||||
}
|
||||
// Wait for initial render and verify we're on Vim Mode (first setting)
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('● Vim Mode');
|
||||
});
|
||||
|
||||
// Navigate to Disable Auto Update setting and verify we're there
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('● Disable Auto Update');
|
||||
});
|
||||
|
||||
// Toggle the setting
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
@@ -413,10 +392,10 @@ describe('SettingsDialog', () => {
|
||||
});
|
||||
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['general.vimMode']),
|
||||
new Set<string>(['general.disableAutoUpdate']),
|
||||
{
|
||||
general: {
|
||||
vimMode: true,
|
||||
disableAutoUpdate: true,
|
||||
},
|
||||
},
|
||||
expect.any(LoadedSettings),
|
||||
@@ -427,10 +406,51 @@ describe('SettingsDialog', () => {
|
||||
});
|
||||
|
||||
describe('enum values', () => {
|
||||
enum StringEnum {
|
||||
FOO = 'foo',
|
||||
BAR = 'bar',
|
||||
BAZ = 'baz',
|
||||
}
|
||||
|
||||
const SETTING: SettingDefinition = {
|
||||
type: 'enum',
|
||||
label: 'Theme',
|
||||
options: [
|
||||
{
|
||||
label: 'Foo',
|
||||
value: StringEnum.FOO,
|
||||
},
|
||||
{
|
||||
label: 'Bar',
|
||||
value: StringEnum.BAR,
|
||||
},
|
||||
{
|
||||
label: 'Baz',
|
||||
value: StringEnum.BAZ,
|
||||
},
|
||||
],
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: StringEnum.BAR,
|
||||
description: 'The color theme for the UI.',
|
||||
showInDialog: true,
|
||||
};
|
||||
|
||||
const FAKE_SCHEMA: SettingsSchemaType = {
|
||||
ui: {
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
theme: {
|
||||
...SETTING,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as SettingsSchemaType;
|
||||
|
||||
it('toggles enum values with the enter key', async () => {
|
||||
vi.mocked(saveModifiedSettings).mockClear();
|
||||
|
||||
// Use real schema - first setting "Tool Approval Mode" is an enum
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
|
||||
const settings = createMockSettings();
|
||||
const onSelect = vi.fn();
|
||||
const component = (
|
||||
@@ -439,30 +459,24 @@ describe('SettingsDialog', () => {
|
||||
</KeypressProvider>
|
||||
);
|
||||
|
||||
const { stdin, unmount, lastFrame } = render(component);
|
||||
const { stdin, unmount } = render(component);
|
||||
|
||||
// Verify we're on Tool Approval Mode (first setting, an enum)
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('● Tool Approval Mode');
|
||||
});
|
||||
|
||||
// Press Enter to cycle the enum value
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
});
|
||||
// Press Enter to toggle current setting
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
await wait();
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
await wait();
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Tool Approval Mode cycles through enum values
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['tools.approvalMode']),
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
approvalMode: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
new Set<string>(['ui.theme']),
|
||||
{
|
||||
ui: {
|
||||
theme: StringEnum.BAZ,
|
||||
},
|
||||
},
|
||||
expect.any(LoadedSettings),
|
||||
SettingScope.User,
|
||||
);
|
||||
@@ -472,10 +486,10 @@ describe('SettingsDialog', () => {
|
||||
|
||||
it('loops back when reaching the end of an enum', async () => {
|
||||
vi.mocked(saveModifiedSettings).mockClear();
|
||||
// Use Tool Approval Mode set to YOLO (last value) to test looping back to first
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
|
||||
const settings = createMockSettings({
|
||||
tools: {
|
||||
approvalMode: 'yolo', // Last enum value
|
||||
ui: {
|
||||
theme: StringEnum.BAZ,
|
||||
},
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
@@ -485,30 +499,24 @@ describe('SettingsDialog', () => {
|
||||
</KeypressProvider>
|
||||
);
|
||||
|
||||
const { stdin, unmount, lastFrame } = render(component);
|
||||
const { stdin, unmount } = render(component);
|
||||
|
||||
// Verify we're on Tool Approval Mode (first setting)
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('● Tool Approval Mode');
|
||||
});
|
||||
|
||||
// Press Enter to cycle - should loop back to first value (Plan)
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
});
|
||||
// Press Enter to toggle current setting
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
await wait();
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
await wait();
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should loop back to first enum value (Plan)
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['tools.approvalMode']),
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
approvalMode: 'plan', // First enum value after YOLO
|
||||
}),
|
||||
}),
|
||||
new Set<string>(['ui.theme']),
|
||||
{
|
||||
ui: {
|
||||
theme: StringEnum.FOO,
|
||||
},
|
||||
},
|
||||
expect.any(LoadedSettings),
|
||||
SettingScope.User,
|
||||
);
|
||||
@@ -588,15 +596,15 @@ describe('SettingsDialog', () => {
|
||||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Tool Approval Mode');
|
||||
expect(lastFrame()).toContain('Vim Mode');
|
||||
});
|
||||
|
||||
// The UI should show settings mode is active (scope is in separate view)
|
||||
expect(lastFrame()).toContain('● Tool Approval Mode'); // Settings section active
|
||||
expect(lastFrame()).not.toContain('Apply To'); // Scope is in a separate view
|
||||
// The UI should show the settings section is active and scope section is inactive
|
||||
expect(lastFrame()).toContain('● Vim Mode'); // Settings section active
|
||||
expect(lastFrame()).toContain(' Apply To'); // Scope section inactive
|
||||
|
||||
// This test validates the initial state - scope selection is now
|
||||
// accessed via Tab key, not shown alongside settings
|
||||
// This test validates the initial state - scope selection behavior
|
||||
// is complex due to keypress handling, so we focus on state validation
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -660,12 +668,12 @@ describe('SettingsDialog', () => {
|
||||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Tool Approval Mode');
|
||||
expect(lastFrame()).toContain('Hide Window Title');
|
||||
});
|
||||
|
||||
// Verify the dialog is rendered properly (scope is in separate view)
|
||||
// Verify the dialog is rendered properly
|
||||
expect(lastFrame()).toContain('Settings');
|
||||
expect(lastFrame()).not.toContain('Apply To'); // Scope is in a separate view
|
||||
expect(lastFrame()).toContain('Apply To');
|
||||
|
||||
// This test validates rendering - escape key behavior depends on complex
|
||||
// keypress handling that's difficult to test reliably in this environment
|
||||
@@ -866,40 +874,17 @@ describe('SettingsDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should keep restart prompt when switching scopes', async () => {
|
||||
it('should clear restart prompt when switching scopes', async () => {
|
||||
const settings = createMockSettings();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, lastFrame, unmount } = render(
|
||||
const { unmount } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Trigger a restart-required setting change: navigate to "Language: UI" (2nd item) and toggle it.
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
await wait();
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
await wait();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'To see changes, Qwen Code must be restarted',
|
||||
);
|
||||
});
|
||||
|
||||
// Switch scopes; restart prompt should remain visible.
|
||||
stdin.write(TerminalKeys.TAB as string);
|
||||
await wait();
|
||||
stdin.write('2');
|
||||
await wait();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'To see changes, Qwen Code must be restarted',
|
||||
);
|
||||
});
|
||||
|
||||
// Restart prompt should be cleared when switching scopes
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -944,44 +929,6 @@ describe('SettingsDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Output Language', () => {
|
||||
it('treats empty output language as auto', async () => {
|
||||
const settings = createMockSettings({
|
||||
general: { outputLanguage: 'en' },
|
||||
});
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={() => {}} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Navigate to "Language: Model" (3rd item), start editing, then commit empty.
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
await wait();
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
await wait();
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
await wait();
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
await wait();
|
||||
|
||||
// Empty input should set 'auto' in settings (rule file is updated on restart)
|
||||
const outputLanguageCall = vi
|
||||
.mocked(saveModifiedSettings)
|
||||
.mock.calls.find((call) =>
|
||||
(call[0] as Set<string>).has('general.outputLanguage'),
|
||||
);
|
||||
expect(outputLanguageCall).toBeTruthy();
|
||||
// Should save 'auto' to settings
|
||||
expect(outputLanguageCall?.[1]).toMatchObject({
|
||||
general: { outputLanguage: OUTPUT_LANGUAGE_AUTO },
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Shortcuts Edge Cases', () => {
|
||||
it('should handle rapid key presses gracefully', async () => {
|
||||
const settings = createMockSettings();
|
||||
@@ -1071,15 +1018,15 @@ describe('SettingsDialog', () => {
|
||||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Tool Approval Mode');
|
||||
expect(lastFrame()).toContain('Vim Mode');
|
||||
});
|
||||
|
||||
// Verify initial state: settings mode active (scope is in separate view)
|
||||
expect(lastFrame()).toContain('● Tool Approval Mode'); // Settings mode active
|
||||
expect(lastFrame()).not.toContain('Apply To'); // Scope is in a separate view
|
||||
// Verify initial state: settings section active, scope section inactive
|
||||
expect(lastFrame()).toContain('● Vim Mode'); // Settings section active
|
||||
expect(lastFrame()).toContain(' Apply To'); // Scope section inactive
|
||||
|
||||
// This test validates the rendered UI structure for tab navigation
|
||||
// Tab now switches between settings view and scope view
|
||||
// Actual tab behavior testing is complex due to keypress handling
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -1133,19 +1080,20 @@ describe('SettingsDialog', () => {
|
||||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Tool Approval Mode');
|
||||
expect(lastFrame()).toContain('Vim Mode');
|
||||
});
|
||||
|
||||
// Verify the complete UI is rendered (scope is in separate view)
|
||||
// Verify the complete UI is rendered with all necessary sections
|
||||
expect(lastFrame()).toContain('Settings'); // Title
|
||||
expect(lastFrame()).toContain('● Tool Approval Mode'); // Active setting
|
||||
expect(lastFrame()).not.toContain('Apply To'); // Scope is in a separate view (Tab to access)
|
||||
expect(lastFrame()).toContain('● Vim Mode'); // Active setting
|
||||
expect(lastFrame()).toContain('Apply To'); // Scope section
|
||||
expect(lastFrame()).toContain('User Settings'); // Scope options (no numbers when settings focused)
|
||||
expect(lastFrame()).toContain(
|
||||
'(Use Enter to select, Tab to configure scope)',
|
||||
'(Use Enter to select, Tab to change focus)',
|
||||
); // Help text
|
||||
|
||||
// This test validates the complete UI structure is available for user workflow
|
||||
// Scope selection is now accessed via Tab key (view switching like ThemeDialog)
|
||||
// Individual interactions are tested in focused unit tests
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -1327,6 +1275,7 @@ describe('SettingsDialog', () => {
|
||||
ui: {
|
||||
hideWindowTitle: true,
|
||||
hideTips: true,
|
||||
showMemoryUsage: true,
|
||||
showLineNumbers: true,
|
||||
showCitations: true,
|
||||
accessibility: {
|
||||
@@ -1375,13 +1324,16 @@ describe('SettingsDialog', () => {
|
||||
disableAutoUpdate: true,
|
||||
},
|
||||
ui: {
|
||||
showMemoryUsage: true,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
tools: {
|
||||
truncateToolOutputThreshold: 50000,
|
||||
truncateToolOutputLines: 1000,
|
||||
},
|
||||
context: {},
|
||||
context: {
|
||||
discoveryMaxDirs: 500,
|
||||
},
|
||||
model: {
|
||||
maxSessionTurns: 100,
|
||||
skipNextSpeakerCheck: false,
|
||||
@@ -1423,7 +1375,9 @@ describe('SettingsDialog', () => {
|
||||
vimMode: true,
|
||||
disableAutoUpdate: false,
|
||||
},
|
||||
ui: {},
|
||||
ui: {
|
||||
showMemoryUsage: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
const onSelect = vi.fn();
|
||||
@@ -1484,6 +1438,7 @@ describe('SettingsDialog', () => {
|
||||
disableLoadingPhrases: true,
|
||||
screenReader: true,
|
||||
},
|
||||
showMemoryUsage: true,
|
||||
showLineNumbers: true,
|
||||
},
|
||||
general: {
|
||||
@@ -1511,6 +1466,7 @@ describe('SettingsDialog', () => {
|
||||
disableFuzzySearch: true,
|
||||
},
|
||||
loadMemoryFromIncludeDirectories: true,
|
||||
discoveryMaxDirs: 100,
|
||||
},
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
@@ -1564,6 +1520,7 @@ describe('SettingsDialog', () => {
|
||||
ui: {
|
||||
hideWindowTitle: false,
|
||||
hideTips: false,
|
||||
showMemoryUsage: false,
|
||||
showLineNumbers: false,
|
||||
showCitations: false,
|
||||
accessibility: {
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { LoadedSettings, Settings } from '../../config/settings.js';
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
getDialogSettingKeys,
|
||||
setPendingSettingValue,
|
||||
getDisplayValue,
|
||||
hasRestartRequiredSettings,
|
||||
saveModifiedSettings,
|
||||
getSettingDefinition,
|
||||
isDefaultValue,
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
getNestedValue,
|
||||
getEffectiveValue,
|
||||
} from '../../utils/settingsUtils.js';
|
||||
import { updateOutputLanguageFile } from '../../utils/languageUtils.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { type Config } from '@qwen-code/qwen-code-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
@@ -58,8 +57,10 @@ export function SettingsDialog({
|
||||
// Get vim mode context to sync vim mode changes
|
||||
const { vimEnabled, toggleVimEnabled } = useVimMode();
|
||||
|
||||
// Mode state: 'settings' or 'scope' (view switching like ThemeDialog)
|
||||
const [mode, setMode] = useState<'settings' | 'scope'>('settings');
|
||||
// Focus state: 'settings' or 'scope'
|
||||
const [focusSection, setFocusSection] = useState<'settings' | 'scope'>(
|
||||
'settings',
|
||||
);
|
||||
// Scope selector state (User by default)
|
||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||
SettingScope.User,
|
||||
@@ -68,6 +69,7 @@ export function SettingsDialog({
|
||||
const [activeSettingIndex, setActiveSettingIndex] = useState(0);
|
||||
// Scroll offset for settings
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
|
||||
|
||||
// Local pending settings state for the selected scope
|
||||
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
|
||||
@@ -87,33 +89,33 @@ export function SettingsDialog({
|
||||
>(new Map());
|
||||
|
||||
// Track restart-required settings across scope changes
|
||||
const [restartRequiredSettings, setRestartRequiredSettings] = useState<
|
||||
const [_restartRequiredSettings, setRestartRequiredSettings] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
|
||||
const showRestartPrompt = restartRequiredSettings.size > 0;
|
||||
|
||||
useEffect(() => {
|
||||
// Base settings for selected scope
|
||||
let updated = structuredClone(settings.forScope(selectedScope).settings);
|
||||
// Overlay globally pending (unsaved) changes so user sees their modifications in any scope
|
||||
const newModified = new Set<string>();
|
||||
const newRestartRequired = new Set<string>();
|
||||
for (const [key, value] of globalPendingChanges.entries()) {
|
||||
const def = getSettingDefinition(key);
|
||||
if (def?.type === 'boolean' && typeof value === 'boolean') {
|
||||
updated = setPendingSettingValue(key, value, updated);
|
||||
} else if (
|
||||
(def?.type === 'number' && typeof value === 'number') ||
|
||||
(def?.type === 'string' && typeof value === 'string') ||
|
||||
(def?.type === 'enum' &&
|
||||
(typeof value === 'string' || typeof value === 'number'))
|
||||
(def?.type === 'string' && typeof value === 'string')
|
||||
) {
|
||||
updated = setPendingSettingValueAny(key, value, updated);
|
||||
}
|
||||
newModified.add(key);
|
||||
if (requiresRestart(key)) newRestartRequired.add(key);
|
||||
}
|
||||
setPendingSettings(updated);
|
||||
setModifiedSettings(newModified);
|
||||
setRestartRequiredSettings(newRestartRequired);
|
||||
setShowRestartPrompt(newRestartRequired.size > 0);
|
||||
}, [selectedScope, settings, globalPendingChanges]);
|
||||
|
||||
const generateSettingsItems = () => {
|
||||
@@ -154,6 +156,10 @@ export function SettingsDialog({
|
||||
);
|
||||
}
|
||||
|
||||
setPendingSettings((prev) =>
|
||||
setPendingSettingValue(key, newValue as boolean, prev),
|
||||
);
|
||||
|
||||
if (!requiresRestart(key)) {
|
||||
const immediateSettings = new Set([key]);
|
||||
const immediateSettingsObject = setPendingSettingValueAny(
|
||||
@@ -223,22 +229,31 @@ export function SettingsDialog({
|
||||
structuredClone(settings.forScope(selectedScope).settings),
|
||||
);
|
||||
} else {
|
||||
// For restart-required settings, save immediately but show restart prompt
|
||||
const immediateSettings = new Set([key]);
|
||||
const immediateSettingsObject = setPendingSettingValueAny(
|
||||
key,
|
||||
newValue,
|
||||
{} as Settings,
|
||||
);
|
||||
saveModifiedSettings(
|
||||
immediateSettings,
|
||||
immediateSettingsObject,
|
||||
settings,
|
||||
selectedScope,
|
||||
);
|
||||
// For restart-required settings, track as modified
|
||||
setModifiedSettings((prev) => {
|
||||
const updated = new Set(prev).add(key);
|
||||
const needsRestart = hasRestartRequiredSettings(updated);
|
||||
console.log(
|
||||
`[DEBUG SettingsDialog] Modified settings:`,
|
||||
Array.from(updated),
|
||||
'Needs restart:',
|
||||
needsRestart,
|
||||
);
|
||||
if (needsRestart) {
|
||||
setShowRestartPrompt(true);
|
||||
setRestartRequiredSettings((prevRestart) =>
|
||||
new Set(prevRestart).add(key),
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Mark as needing restart and show prompt
|
||||
setRestartRequiredSettings((prev) => new Set(prev).add(key));
|
||||
// Add/update pending change globally so it persists across scopes
|
||||
setGlobalPendingChanges((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(key, newValue as PendingValue);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -281,7 +296,7 @@ export function SettingsDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: string | number | undefined;
|
||||
let parsed: string | number;
|
||||
if (type === 'number') {
|
||||
const numParsed = Number(editBuffer.trim());
|
||||
if (Number.isNaN(numParsed)) {
|
||||
@@ -294,32 +309,19 @@ export function SettingsDialog({
|
||||
parsed = numParsed;
|
||||
} else {
|
||||
// For strings, use the buffer as is.
|
||||
// Special handling for outputLanguage: empty input means 'auto'
|
||||
if (key === 'general.outputLanguage') {
|
||||
const trimmed = editBuffer.trim();
|
||||
parsed = trimmed === '' ? 'auto' : trimmed;
|
||||
} else {
|
||||
parsed = editBuffer;
|
||||
}
|
||||
parsed = editBuffer;
|
||||
}
|
||||
|
||||
// Update pending
|
||||
setPendingSettings((prev) =>
|
||||
parsed === undefined
|
||||
? setPendingSettingValueAny(
|
||||
key,
|
||||
undefined as unknown as SettingsValue,
|
||||
prev,
|
||||
)
|
||||
: setPendingSettingValueAny(key, parsed, prev),
|
||||
);
|
||||
setPendingSettings((prev) => setPendingSettingValueAny(key, parsed, prev));
|
||||
|
||||
if (!requiresRestart(key)) {
|
||||
const immediateSettings = new Set([key]);
|
||||
const immediateSettingsObject =
|
||||
parsed === undefined
|
||||
? ({} as Settings)
|
||||
: setPendingSettingValueAny(key, parsed, {} as Settings);
|
||||
const immediateSettingsObject = setPendingSettingValueAny(
|
||||
key,
|
||||
parsed,
|
||||
{} as Settings,
|
||||
);
|
||||
saveModifiedSettings(
|
||||
immediateSettings,
|
||||
immediateSettingsObject,
|
||||
@@ -347,26 +349,25 @@ export function SettingsDialog({
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
// For restart-required settings, save immediately but show restart prompt
|
||||
const immediateSettings = new Set([key]);
|
||||
const immediateSettingsObject =
|
||||
parsed === undefined
|
||||
? ({} as Settings)
|
||||
: setPendingSettingValueAny(key, parsed, {} as Settings);
|
||||
saveModifiedSettings(
|
||||
immediateSettings,
|
||||
immediateSettingsObject,
|
||||
settings,
|
||||
selectedScope,
|
||||
);
|
||||
// Mark as modified and needing restart
|
||||
setModifiedSettings((prev) => {
|
||||
const updated = new Set(prev).add(key);
|
||||
const needsRestart = hasRestartRequiredSettings(updated);
|
||||
if (needsRestart) {
|
||||
setShowRestartPrompt(true);
|
||||
setRestartRequiredSettings((prevRestart) =>
|
||||
new Set(prevRestart).add(key),
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Update output language rule file immediately (no restart needed for LLM effect)
|
||||
if (key === 'general.outputLanguage' && typeof parsed === 'string') {
|
||||
updateOutputLanguageFile(parsed);
|
||||
}
|
||||
|
||||
// Mark as needing restart and show prompt
|
||||
setRestartRequiredSettings((prev) => new Set(prev).add(key));
|
||||
// Record pending change globally for persistence across scopes
|
||||
setGlobalPendingChanges((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(key, parsed as PendingValue);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setEditingKey(null);
|
||||
@@ -380,13 +381,15 @@ export function SettingsDialog({
|
||||
|
||||
const handleScopeSelect = (scope: SettingScope) => {
|
||||
handleScopeHighlight(scope);
|
||||
setMode('settings');
|
||||
setFocusSection('settings');
|
||||
};
|
||||
|
||||
// Height constraint calculations similar to ThemeDialog
|
||||
const DIALOG_PADDING = 2;
|
||||
const SETTINGS_TITLE_HEIGHT = 2; // "Settings" title + spacing
|
||||
const SCROLL_ARROWS_HEIGHT = 2; // Up and down arrows
|
||||
const SPACING_HEIGHT = 1; // Space between settings list and scope
|
||||
const SCOPE_SELECTION_HEIGHT = 4; // Apply To section height
|
||||
const BOTTOM_HELP_TEXT_HEIGHT = 1; // Help text
|
||||
const RESTART_PROMPT_HEIGHT = showRestartPrompt ? 1 : 0;
|
||||
|
||||
@@ -394,28 +397,71 @@ export function SettingsDialog({
|
||||
availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
|
||||
currentAvailableTerminalHeight -= 2; // Top and bottom borders
|
||||
|
||||
// Calculate fixed height (scope selection is now in a separate view, not included here)
|
||||
const totalFixedHeight =
|
||||
// Start with basic fixed height (without scope selection)
|
||||
let totalFixedHeight =
|
||||
DIALOG_PADDING +
|
||||
SETTINGS_TITLE_HEIGHT +
|
||||
SCROLL_ARROWS_HEIGHT +
|
||||
SPACING_HEIGHT +
|
||||
BOTTOM_HELP_TEXT_HEIGHT +
|
||||
RESTART_PROMPT_HEIGHT;
|
||||
|
||||
// Calculate how much space we have for settings
|
||||
const availableHeightForSettings = Math.max(
|
||||
let availableHeightForSettings = Math.max(
|
||||
1,
|
||||
currentAvailableTerminalHeight - totalFixedHeight,
|
||||
);
|
||||
|
||||
// Each setting item takes 1 line
|
||||
const maxVisibleItems = Math.max(1, availableHeightForSettings);
|
||||
// Each setting item takes 2 lines (the setting row + spacing)
|
||||
let maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 2));
|
||||
|
||||
// Decide whether to show scope selection based on remaining space
|
||||
let showScopeSelection = true;
|
||||
|
||||
// If we have limited height, prioritize showing more settings over scope selection
|
||||
if (availableTerminalHeight && availableTerminalHeight < 25) {
|
||||
// For very limited height, hide scope selection to show more settings
|
||||
const totalWithScope = totalFixedHeight + SCOPE_SELECTION_HEIGHT;
|
||||
const availableWithScope = Math.max(
|
||||
1,
|
||||
currentAvailableTerminalHeight - totalWithScope,
|
||||
);
|
||||
const maxItemsWithScope = Math.max(1, Math.floor(availableWithScope / 2));
|
||||
|
||||
// If hiding scope selection allows us to show significantly more settings, do it
|
||||
if (maxVisibleItems > maxItemsWithScope + 1) {
|
||||
showScopeSelection = false;
|
||||
} else {
|
||||
// Otherwise include scope selection and recalculate
|
||||
totalFixedHeight += SCOPE_SELECTION_HEIGHT;
|
||||
availableHeightForSettings = Math.max(
|
||||
1,
|
||||
currentAvailableTerminalHeight - totalFixedHeight,
|
||||
);
|
||||
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 2));
|
||||
}
|
||||
} else {
|
||||
// For normal height, include scope selection
|
||||
totalFixedHeight += SCOPE_SELECTION_HEIGHT;
|
||||
availableHeightForSettings = Math.max(
|
||||
1,
|
||||
currentAvailableTerminalHeight - totalFixedHeight,
|
||||
);
|
||||
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 2));
|
||||
}
|
||||
|
||||
// Use the calculated maxVisibleItems or fall back to the original maxItemsToShow
|
||||
const effectiveMaxItemsToShow = availableTerminalHeight
|
||||
? Math.min(maxVisibleItems, items.length)
|
||||
: maxItemsToShow;
|
||||
|
||||
// Ensure focus stays on settings when scope selection is hidden
|
||||
React.useEffect(() => {
|
||||
if (!showScopeSelection && focusSection === 'scope') {
|
||||
setFocusSection('settings');
|
||||
}
|
||||
}, [showScopeSelection, focusSection]);
|
||||
|
||||
// Scroll logic for settings
|
||||
const visibleItems = items.slice(
|
||||
scrollOffset,
|
||||
@@ -428,10 +474,10 @@ export function SettingsDialog({
|
||||
useKeypress(
|
||||
(key) => {
|
||||
const { name, ctrl } = key;
|
||||
if (name === 'tab') {
|
||||
setMode((prev) => (prev === 'settings' ? 'scope' : 'settings'));
|
||||
if (name === 'tab' && showScopeSelection) {
|
||||
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
|
||||
}
|
||||
if (mode === 'settings') {
|
||||
if (focusSection === 'settings') {
|
||||
// If editing, capture input and control keys
|
||||
if (editingKey) {
|
||||
const definition = getSettingDefinition(editingKey);
|
||||
@@ -553,18 +599,6 @@ export function SettingsDialog({
|
||||
}
|
||||
} else if (name === 'return' || name === 'space') {
|
||||
const currentItem = items[activeSettingIndex];
|
||||
if (currentItem?.value === 'ui.theme') {
|
||||
if (name === 'return') {
|
||||
onSelect('ui.theme', selectedScope);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (currentItem?.value === 'general.preferredEditor') {
|
||||
if (name === 'return') {
|
||||
onSelect('general.preferredEditor', selectedScope);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
currentItem?.type === 'number' ||
|
||||
currentItem?.type === 'string'
|
||||
@@ -693,9 +727,6 @@ export function SettingsDialog({
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setRestartRequiredSettings((prev) =>
|
||||
new Set(prev).add(currentSetting.value),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -725,6 +756,7 @@ export function SettingsDialog({
|
||||
});
|
||||
}
|
||||
|
||||
setShowRestartPrompt(false);
|
||||
setRestartRequiredSettings(new Set()); // Clear restart-required settings
|
||||
if (onRestartRequest) onRestartRequest();
|
||||
}
|
||||
@@ -743,95 +775,97 @@ export function SettingsDialog({
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
flexDirection="row"
|
||||
padding={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
{mode === 'settings' ? (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Text bold={mode === 'settings'} wrap="truncate">
|
||||
{mode === 'settings' ? '> ' : ' '}
|
||||
{t('Settings')}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
{showScrollUp && <Text color={theme.text.secondary}>▲</Text>}
|
||||
{visibleItems.map((item, idx) => {
|
||||
const isActive =
|
||||
mode === 'settings' && activeSettingIndex === idx + scrollOffset;
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Text bold={focusSection === 'settings'} wrap="truncate">
|
||||
{focusSection === 'settings' ? '> ' : ' '}
|
||||
{t('Settings')}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
{showScrollUp && <Text color={theme.text.secondary}>▲</Text>}
|
||||
{visibleItems.map((item, idx) => {
|
||||
const isActive =
|
||||
focusSection === 'settings' &&
|
||||
activeSettingIndex === idx + scrollOffset;
|
||||
|
||||
const scopeSettings = settings.forScope(selectedScope).settings;
|
||||
const mergedSettings = settings.merged;
|
||||
const scopeSettings = settings.forScope(selectedScope).settings;
|
||||
const mergedSettings = settings.merged;
|
||||
|
||||
let displayValue: string;
|
||||
if (editingKey === item.value) {
|
||||
// Show edit buffer with advanced cursor highlighting
|
||||
if (cursorVisible && editCursorPos < cpLen(editBuffer)) {
|
||||
// Cursor is in the middle or at start of text
|
||||
const beforeCursor = cpSlice(editBuffer, 0, editCursorPos);
|
||||
const atCursor = cpSlice(
|
||||
editBuffer,
|
||||
editCursorPos,
|
||||
editCursorPos + 1,
|
||||
);
|
||||
const afterCursor = cpSlice(editBuffer, editCursorPos + 1);
|
||||
displayValue =
|
||||
beforeCursor + chalk.inverse(atCursor) + afterCursor;
|
||||
} else if (cursorVisible && editCursorPos >= cpLen(editBuffer)) {
|
||||
// Cursor is at the end - show inverted space
|
||||
displayValue = editBuffer + chalk.inverse(' ');
|
||||
} else {
|
||||
// Cursor not visible
|
||||
displayValue = editBuffer;
|
||||
}
|
||||
} else if (item.type === 'number' || item.type === 'string') {
|
||||
// For numbers/strings, get the actual current value from pending settings
|
||||
const path = item.value.split('.');
|
||||
const currentValue = getNestedValue(pendingSettings, path);
|
||||
|
||||
const defaultValue = getDefaultValue(item.value);
|
||||
|
||||
if (currentValue !== undefined && currentValue !== null) {
|
||||
displayValue = String(currentValue);
|
||||
} else {
|
||||
displayValue =
|
||||
defaultValue !== undefined && defaultValue !== null
|
||||
? String(defaultValue)
|
||||
: '';
|
||||
}
|
||||
|
||||
// Add * if value differs from default OR if currently being modified
|
||||
const isModified = modifiedSettings.has(item.value);
|
||||
const effectiveCurrentValue =
|
||||
currentValue !== undefined && currentValue !== null
|
||||
? currentValue
|
||||
: defaultValue;
|
||||
const isDifferentFromDefault =
|
||||
effectiveCurrentValue !== defaultValue;
|
||||
|
||||
if (isDifferentFromDefault || isModified) {
|
||||
displayValue += '*';
|
||||
}
|
||||
} else {
|
||||
// For booleans and other types, use existing logic
|
||||
displayValue = getDisplayValue(
|
||||
item.value,
|
||||
scopeSettings,
|
||||
mergedSettings,
|
||||
modifiedSettings,
|
||||
pendingSettings,
|
||||
let displayValue: string;
|
||||
if (editingKey === item.value) {
|
||||
// Show edit buffer with advanced cursor highlighting
|
||||
if (cursorVisible && editCursorPos < cpLen(editBuffer)) {
|
||||
// Cursor is in the middle or at start of text
|
||||
const beforeCursor = cpSlice(editBuffer, 0, editCursorPos);
|
||||
const atCursor = cpSlice(
|
||||
editBuffer,
|
||||
editCursorPos,
|
||||
editCursorPos + 1,
|
||||
);
|
||||
const afterCursor = cpSlice(editBuffer, editCursorPos + 1);
|
||||
displayValue =
|
||||
beforeCursor + chalk.inverse(atCursor) + afterCursor;
|
||||
} else if (cursorVisible && editCursorPos >= cpLen(editBuffer)) {
|
||||
// Cursor is at the end - show inverted space
|
||||
displayValue = editBuffer + chalk.inverse(' ');
|
||||
} else {
|
||||
// Cursor not visible
|
||||
displayValue = editBuffer;
|
||||
}
|
||||
const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings);
|
||||
} else if (item.type === 'number' || item.type === 'string') {
|
||||
// For numbers/strings, get the actual current value from pending settings
|
||||
const path = item.value.split('.');
|
||||
const currentValue = getNestedValue(pendingSettings, path);
|
||||
|
||||
// Generate scope message for this setting
|
||||
const scopeMessage = getScopeMessageForSetting(
|
||||
const defaultValue = getDefaultValue(item.value);
|
||||
|
||||
if (currentValue !== undefined && currentValue !== null) {
|
||||
displayValue = String(currentValue);
|
||||
} else {
|
||||
displayValue =
|
||||
defaultValue !== undefined && defaultValue !== null
|
||||
? String(defaultValue)
|
||||
: '';
|
||||
}
|
||||
|
||||
// Add * if value differs from default OR if currently being modified
|
||||
const isModified = modifiedSettings.has(item.value);
|
||||
const effectiveCurrentValue =
|
||||
currentValue !== undefined && currentValue !== null
|
||||
? currentValue
|
||||
: defaultValue;
|
||||
const isDifferentFromDefault =
|
||||
effectiveCurrentValue !== defaultValue;
|
||||
|
||||
if (isDifferentFromDefault || isModified) {
|
||||
displayValue += '*';
|
||||
}
|
||||
} else {
|
||||
// For booleans and other types, use existing logic
|
||||
displayValue = getDisplayValue(
|
||||
item.value,
|
||||
selectedScope,
|
||||
settings,
|
||||
scopeSettings,
|
||||
mergedSettings,
|
||||
modifiedSettings,
|
||||
pendingSettings,
|
||||
);
|
||||
}
|
||||
const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings);
|
||||
|
||||
return (
|
||||
<Box key={item.value} flexDirection="row" alignItems="center">
|
||||
// Generate scope message for this setting
|
||||
const scopeMessage = getScopeMessageForSetting(
|
||||
item.value,
|
||||
selectedScope,
|
||||
settings,
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.value}>
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text
|
||||
color={
|
||||
@@ -841,10 +875,9 @@ export function SettingsDialog({
|
||||
{isActive ? '●' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} flexShrink={1}>
|
||||
<Box minWidth={50}>
|
||||
<Text
|
||||
color={isActive ? theme.status.success : theme.text.primary}
|
||||
wrap="truncate"
|
||||
>
|
||||
{item.label}
|
||||
{scopeMessage && (
|
||||
@@ -852,47 +885,53 @@ export function SettingsDialog({
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={1} flexShrink={0}>
|
||||
<Text
|
||||
color={
|
||||
isActive
|
||||
? theme.status.success
|
||||
: shouldBeGreyedOut
|
||||
? theme.text.secondary
|
||||
: theme.text.primary
|
||||
}
|
||||
wrap="truncate"
|
||||
>
|
||||
{displayValue}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minWidth={3} />
|
||||
<Text
|
||||
color={
|
||||
isActive
|
||||
? theme.status.success
|
||||
: shouldBeGreyedOut
|
||||
? theme.text.secondary
|
||||
: theme.text.primary
|
||||
}
|
||||
>
|
||||
{displayValue}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
<Box height={1} />
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{showScrollDown && <Text color={theme.text.secondary}>▼</Text>}
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Scope Selection - conditionally visible based on height constraints */}
|
||||
{showScopeSelection && (
|
||||
<Box marginTop={1}>
|
||||
<ScopeSelector
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={focusSection === 'scope'}
|
||||
initialScope={selectedScope}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box height={1} />
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('(Use Enter to select{{tabText}})', {
|
||||
tabText: showScopeSelection ? t(', Tab to change focus') : '',
|
||||
})}
|
||||
{showScrollDown && <Text color={theme.text.secondary}>▼</Text>}
|
||||
</Box>
|
||||
) : (
|
||||
<ScopeSelector
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={mode === 'scope'}
|
||||
initialScope={selectedScope}
|
||||
/>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
{mode === 'settings'
|
||||
? t('(Use Enter to select, Tab to configure scope)')
|
||||
: t('(Use Enter to apply scope, Tab to go back)')}
|
||||
</Text>
|
||||
{showRestartPrompt && (
|
||||
<Text color={theme.status.warning}>
|
||||
{t(
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{showRestartPrompt && (
|
||||
<Text color={theme.status.warning}>
|
||||
{t(
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,13 +160,11 @@ const ModelUsageTable: React.FC<{
|
||||
interface StatsDisplayProps {
|
||||
duration: string;
|
||||
title?: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
duration,
|
||||
title,
|
||||
width,
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
const { metrics } = stats;
|
||||
@@ -215,7 +213,6 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={width}
|
||||
>
|
||||
{renderTitle()}
|
||||
<Box height={1} />
|
||||
|
||||
@@ -42,7 +42,7 @@ export function SuggestionsDisplay({
|
||||
}: SuggestionsDisplayProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box width={width}>
|
||||
<Box paddingX={1} width={width}>
|
||||
<Text color="gray">Loading suggestions...</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -70,7 +70,7 @@ export function SuggestionsDisplay({
|
||||
mode === 'slash' ? Math.min(maxLabelLength, Math.floor(width * 0.5)) : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Box flexDirection="column" paddingX={1} width={width}>
|
||||
{scrollOffset > 0 && <Text color={theme.text.primary}>▲</Text>}
|
||||
|
||||
{visibleSuggestions.map((suggestion, index) => {
|
||||
|
||||
@@ -258,7 +258,7 @@ def fibonacci(n):
|
||||
+ print(f"Hello, {name}!")
|
||||
`}
|
||||
availableTerminalHeight={diffHeight}
|
||||
contentWidth={colorizeCodeWidth}
|
||||
terminalWidth={colorizeCodeWidth}
|
||||
theme={previewTheme}
|
||||
/>
|
||||
</Box>
|
||||
@@ -278,7 +278,7 @@ def fibonacci(n):
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
{mode === 'theme'
|
||||
? t('(Use Enter to select, Tab to configure scope)')
|
||||
: t('(Use Enter to apply scope, Tab to go back)')}
|
||||
: t('(Use Enter to apply scope, Tab to select theme)')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -4,33 +4,42 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { type Config } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
const startupTips = [
|
||||
'Use /compress when the conversation gets long to summarize history and free up context.',
|
||||
'Start a fresh idea with /clear or /new; the previous session stays available in history.',
|
||||
'Use /bug to submit issues to the maintainers when something goes off.',
|
||||
'Switch auth type quickly with /auth.',
|
||||
'You can run any shell commands from Qwen Code using ! (e.g. !ls).',
|
||||
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.',
|
||||
'You can resume a previous conversation by running qwen --continue or qwen --resume.',
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
|
||||
] as const;
|
||||
|
||||
export const Tips: React.FC = () => {
|
||||
const selectedTip = useMemo(() => {
|
||||
const randomIndex = Math.floor(Math.random() * startupTips.length);
|
||||
return startupTips[randomIndex];
|
||||
}, []);
|
||||
interface TipsProps {
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export const Tips: React.FC<TipsProps> = ({ config }) => {
|
||||
const geminiMdFileCount = config.getGeminiMdFileCount();
|
||||
return (
|
||||
<Box marginLeft={2} marginRight={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Tips: ')}
|
||||
{t(selectedTip)}
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.primary}>{t('Tips for getting started:')}</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
{t('1. Ask questions, edit files, or run commands.')}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
{t('2. Be specific for the best results.')}
|
||||
</Text>
|
||||
{geminiMdFileCount === 0 && (
|
||||
<Text color={theme.text.primary}>
|
||||
3. Create{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
QWEN.md
|
||||
</Text>{' '}
|
||||
{t('files to customize your interactions with Qwen Code.')}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.primary}>
|
||||
{geminiMdFileCount === 0 ? '4.' : '3.'}{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
/help
|
||||
</Text>{' '}
|
||||
{t('for more information.')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -53,13 +53,7 @@ const StatRow: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
interface ToolStatsDisplayProps {
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const ToolStatsDisplay: React.FC<ToolStatsDisplayProps> = ({
|
||||
width,
|
||||
}) => {
|
||||
export const ToolStatsDisplay: React.FC = () => {
|
||||
const { stats } = useSessionStats();
|
||||
const { tools } = stats.metrics;
|
||||
const activeTools = Object.entries(tools.byName).filter(
|
||||
@@ -73,7 +67,6 @@ export const ToolStatsDisplay: React.FC<ToolStatsDisplayProps> = ({
|
||||
borderColor={theme.border.default}
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={width}
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
{t('No tool calls have been made in this session.')}
|
||||
@@ -108,7 +101,7 @@ export const ToolStatsDisplay: React.FC<ToolStatsDisplayProps> = ({
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={width}
|
||||
width={70}
|
||||
>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{t('Tool Stats For Nerds')}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on narrow terminal > complete-footer-narrow 1`] = `" ? for shortcuts 0.1% used"`;
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `"...s/to/make/it/long (main*) no sandbox gemini-pro (100%)"`;
|
||||
|
||||
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on wide terminal > complete-footer-wide 1`] = `" ? for shortcuts 0.1% context used"`;
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `"...directories/to/make/it/long (main*) no sandbox (see /docs) gemini-pro (100% context left)"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `"...directories/to/make/it/long (main*) no sandbox (see /docs)"`;
|
||||
|
||||
@@ -1,137 +1,137 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<HistoryItemDisplay /> > should render a full gemini item when using availableTerminalHeightGemini 1`] = `
|
||||
" ✦ Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
"✦ Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = `
|
||||
" Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
" Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > should render a truncated gemini item 1`] = `
|
||||
" ✦ Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
"✦ Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > should render a truncated gemini_content item 1`] = `
|
||||
" Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
" Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
@@ -20,38 +20,38 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
(r:) commit
|
||||
(r:) commit
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
git commit -m "feat: add search" in src/app"
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
(r:) commit
|
||||
(r:) commit
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
git commit -m "feat: add search" in src/app"
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> Type your message or @path/to/file
|
||||
> Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
! Type your message or @path/to/file
|
||||
! Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
* Type your message or @path/to/file
|
||||
* Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> Type your message or @path/to/file
|
||||
> Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
@@ -6,17 +6,30 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ● Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -27,17 +40,30 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ● Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -48,17 +74,30 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode true* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ● Vim Mode true* │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -69,17 +108,30 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false* │
|
||||
│ ● Vim Mode false* │
|
||||
│ │
|
||||
│ Disable Auto Update false* │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false* │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false* │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -90,17 +142,30 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode (Modified in System) false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ● Vim Mode (Modified in System) false │
|
||||
│ │
|
||||
│ Disable Auto Update (Modified in System) false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -111,17 +176,30 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode (Modified in Workspace) false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ● Vim Mode (Modified in Workspace) false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging (Modified in Workspace) false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -132,17 +210,30 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ● Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -153,17 +244,30 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ● Vim Mode false* │
|
||||
│ │
|
||||
│ Disable Auto Update true* │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false* │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -174,17 +278,30 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ● Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -195,17 +312,30 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode true* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE true* │
|
||||
│ ● Vim Mode true* │
|
||||
│ │
|
||||
│ Disable Auto Update true* │
|
||||
│ │
|
||||
│ Debug Keystroke Logging true* │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title true* │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -4,11 +4,10 @@ exports[`ThemeDialog Snapshots > should render correctly in scope selector mode
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Apply To │
|
||||
│ │
|
||||
│ ● 1. User Settings │
|
||||
│ 2. Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to apply scope, Tab to go back) │
|
||||
│ (Use Enter to apply scope, Tab to select theme) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ToolStatsDisplay /> > should display stats for a single tool correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ test-tool 1 100.0% 100ms │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 1 │
|
||||
│ » Accepted: 1 │
|
||||
│ » Rejected: 0 │
|
||||
│ » Modified: 0 │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 100.0% │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ test-tool 1 100.0% 100ms │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 1 │
|
||||
│ » Accepted: 1 │
|
||||
│ » Rejected: 0 │
|
||||
│ » Modified: 0 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 100.0% │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolStatsDisplay /> > should display stats for multiple tools correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ tool-a 2 50.0% 100ms │
|
||||
│ tool-b 1 100.0% 100ms │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 3 │
|
||||
│ » Accepted: 1 │
|
||||
│ » Rejected: 1 │
|
||||
│ » Modified: 1 │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 33.3% │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ tool-a 2 50.0% 100ms │
|
||||
│ tool-b 1 100.0% 100ms │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 3 │
|
||||
│ » Accepted: 1 │
|
||||
│ » Rejected: 1 │
|
||||
│ » Modified: 1 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 33.3% │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ long-named-tool-for-testi99999999 88.9% 1ms │
|
||||
│ ng-wrapping-and-such 9 │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 222234566 │
|
||||
│ » Accepted: 123456789 │
|
||||
│ » Rejected: 98765432 │
|
||||
│ » Modified: 12345 │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 55.6% │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ long-named-tool-for-testi99999999 88.9% 1ms │
|
||||
│ ng-wrapping-and-such 9 │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 222234566 │
|
||||
│ » Accepted: 123456789 │
|
||||
│ » Rejected: 98765432 │
|
||||
│ » Modified: 12345 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 55.6% │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolStatsDisplay /> > should handle zero decisions gracefully 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ test-tool 1 100.0% 100ms │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 0 │
|
||||
│ » Accepted: 0 │
|
||||
│ » Rejected: 0 │
|
||||
│ » Modified: 0 │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: -- │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ test-tool 1 100.0% 100ms │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 0 │
|
||||
│ » Accepted: 0 │
|
||||
│ » Rejected: 0 │
|
||||
│ » Modified: 0 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: -- │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolStatsDisplay /> > should render "no tool calls" message when there are no active tools 1`] = `
|
||||
|
||||
@@ -35,7 +35,7 @@ index 0000000..e69de29
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiffContent}
|
||||
filename="test.py"
|
||||
contentWidth={80}
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
@@ -63,7 +63,7 @@ index 0000000..e69de29
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiffContent}
|
||||
filename="test.unknown"
|
||||
contentWidth={80}
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
@@ -88,7 +88,7 @@ index 0000000..e69de29
|
||||
`;
|
||||
render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent={newFileDiffContent} contentWidth={80} />
|
||||
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith(
|
||||
@@ -115,7 +115,7 @@ index 0000001..0000002 100644
|
||||
<DiffRenderer
|
||||
diffContent={existingFileDiffContent}
|
||||
filename="test.txt"
|
||||
contentWidth={80}
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
@@ -145,7 +145,7 @@ index 1234567..1234567 100644
|
||||
<DiffRenderer
|
||||
diffContent={noChangeDiff}
|
||||
filename="file.txt"
|
||||
contentWidth={80}
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
@@ -156,7 +156,7 @@ index 1234567..1234567 100644
|
||||
it('should handle empty diff content', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent="" contentWidth={80} />
|
||||
<DiffRenderer diffContent="" terminalWidth={80} />
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).toContain('No diff content');
|
||||
@@ -182,7 +182,7 @@ index 123..456 100644
|
||||
<DiffRenderer
|
||||
diffContent={diffWithGap}
|
||||
filename="file.txt"
|
||||
contentWidth={80}
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
@@ -219,7 +219,7 @@ index abc..def 100644
|
||||
<DiffRenderer
|
||||
diffContent={diffWithSmallGap}
|
||||
filename="file.txt"
|
||||
contentWidth={80}
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
@@ -291,7 +291,7 @@ index 123..789 100644
|
||||
<DiffRenderer
|
||||
diffContent={diffWithMultipleHunks}
|
||||
filename="multi.js"
|
||||
contentWidth={terminalWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
availableTerminalHeight={height}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
@@ -323,7 +323,7 @@ fileDiff Index: file.txt
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff}
|
||||
filename="TEST"
|
||||
contentWidth={80}
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
@@ -353,7 +353,7 @@ fileDiff Index: Dockerfile
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff}
|
||||
filename="Dockerfile"
|
||||
contentWidth={80}
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
@@ -84,7 +84,7 @@ interface DiffRendererProps {
|
||||
filename?: string;
|
||||
tabWidth?: number;
|
||||
availableTerminalHeight?: number;
|
||||
contentWidth: number;
|
||||
terminalWidth: number;
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
filename,
|
||||
tabWidth = DEFAULT_TAB_WIDTH,
|
||||
availableTerminalHeight,
|
||||
contentWidth,
|
||||
terminalWidth,
|
||||
theme,
|
||||
}) => {
|
||||
const screenReaderEnabled = useIsScreenReaderEnabled();
|
||||
@@ -155,7 +155,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
addedContent,
|
||||
language,
|
||||
availableTerminalHeight,
|
||||
contentWidth,
|
||||
terminalWidth,
|
||||
theme,
|
||||
);
|
||||
} else {
|
||||
@@ -164,7 +164,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
filename,
|
||||
tabWidth,
|
||||
availableTerminalHeight,
|
||||
contentWidth,
|
||||
terminalWidth,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ const renderDiffContent = (
|
||||
filename: string | undefined,
|
||||
tabWidth = DEFAULT_TAB_WIDTH,
|
||||
availableTerminalHeight: number | undefined,
|
||||
contentWidth: number,
|
||||
terminalWidth: number,
|
||||
) => {
|
||||
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
|
||||
const normalizedLines = parsedLines.map((line) => ({
|
||||
@@ -238,7 +238,7 @@ const renderDiffContent = (
|
||||
return (
|
||||
<MaxSizedBox
|
||||
maxHeight={availableTerminalHeight}
|
||||
maxWidth={contentWidth}
|
||||
maxWidth={terminalWidth}
|
||||
key={key}
|
||||
>
|
||||
{displayableLines.reduce<React.ReactNode[]>((acc, line, index) => {
|
||||
@@ -260,7 +260,7 @@ const renderDiffContent = (
|
||||
acc.push(
|
||||
<Box key={`gap-${index}`}>
|
||||
<Text wrap="truncate" color={semanticTheme.text.secondary}>
|
||||
{'═'.repeat(contentWidth)}
|
||||
{'═'.repeat(terminalWidth)}
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => {
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.status.error}>{prefix}</Text>
|
||||
</Box>
|
||||
|
||||
@@ -14,14 +14,14 @@ interface GeminiMessageProps {
|
||||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
contentWidth: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
text,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
contentWidth,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const prefix = '✦ ';
|
||||
const prefixWidth = prefix.length;
|
||||
@@ -38,7 +38,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
contentWidth={contentWidth - prefixWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -12,7 +12,7 @@ interface GeminiMessageContentProps {
|
||||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
contentWidth: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -25,7 +25,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
text,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
contentWidth,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const originalPrefix = '✦ ';
|
||||
const prefixWidth = originalPrefix.length;
|
||||
@@ -36,7 +36,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
contentWidth={contentWidth - prefixWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ interface GeminiThoughtMessageProps {
|
||||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
contentWidth: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,13 +24,13 @@ export const GeminiThoughtMessage: React.FC<GeminiThoughtMessageProps> = ({
|
||||
text,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
contentWidth,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const prefix = '✦ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.text.secondary}>{prefix}</Text>
|
||||
</Box>
|
||||
@@ -39,7 +39,7 @@ export const GeminiThoughtMessage: React.FC<GeminiThoughtMessageProps> = ({
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
contentWidth={contentWidth - prefixWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
textColor={theme.text.secondary}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -13,7 +13,7 @@ interface GeminiThoughtMessageContentProps {
|
||||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
contentWidth: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,17 +22,17 @@ interface GeminiThoughtMessageContentProps {
|
||||
*/
|
||||
export const GeminiThoughtMessageContent: React.FC<
|
||||
GeminiThoughtMessageContentProps
|
||||
> = ({ text, isPending, availableTerminalHeight, contentWidth }) => {
|
||||
> = ({ text, isPending, availableTerminalHeight, terminalWidth }) => {
|
||||
const originalPrefix = '✦ ';
|
||||
const prefixWidth = originalPrefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={prefixWidth}>
|
||||
<Box flexDirection="column" paddingLeft={prefixWidth} marginBottom={1}>
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
contentWidth={contentWidth - prefixWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
textColor={theme.text.secondary}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.status.warning}>{prefix}</Text>
|
||||
</Box>
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
contentWidth={80}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
contentWidth={80}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
contentWidth={80}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -162,7 +162,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirmationDetails={details}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
contentWidth={80}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -180,7 +180,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirmationDetails={details}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
contentWidth={80}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -212,7 +212,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirmationDetails={editConfirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
contentWidth={80}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
{
|
||||
settings: {
|
||||
@@ -235,7 +235,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
confirmationDetails={editConfirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
contentWidth={80}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
{
|
||||
settings: {
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface ToolConfirmationMessageProps {
|
||||
config: Config;
|
||||
isFocused?: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
contentWidth: number;
|
||||
terminalWidth: number;
|
||||
compactMode?: boolean;
|
||||
}
|
||||
|
||||
@@ -42,10 +42,11 @@ export const ToolConfirmationMessage: React.FC<
|
||||
config,
|
||||
isFocused = true,
|
||||
availableTerminalHeight,
|
||||
contentWidth,
|
||||
terminalWidth,
|
||||
compactMode = false,
|
||||
}) => {
|
||||
const { onConfirm } = confirmationDetails;
|
||||
const childWidth = terminalWidth - 2; // 2 for padding
|
||||
|
||||
const settings = useSettings();
|
||||
const preferredEditor = settings.merged.general?.preferredEditor as
|
||||
@@ -225,7 +226,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
diffContent={confirmationDetails.fileDiff}
|
||||
filename={confirmationDetails.fileName}
|
||||
availableTerminalHeight={availableBodyContentHeight()}
|
||||
contentWidth={contentWidth}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
@@ -262,7 +263,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
<Box paddingX={1} marginLeft={1}>
|
||||
<MaxSizedBox
|
||||
maxHeight={bodyContentHeight}
|
||||
maxWidth={Math.max(contentWidth, 1)}
|
||||
maxWidth={Math.max(childWidth - 4, 1)}
|
||||
>
|
||||
<Box>
|
||||
<Text color={theme.text.link}>{executionProps.command}</Text>
|
||||
@@ -297,7 +298,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
text={planProps.plan}
|
||||
isPending={false}
|
||||
availableTerminalHeight={availableBodyContentHeight()}
|
||||
contentWidth={contentWidth}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
@@ -396,7 +397,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1} width={contentWidth}>
|
||||
<Box flexDirection="column" padding={1} width={childWidth}>
|
||||
{/* Body Content (Diff Renderer or Command Info) */}
|
||||
{/* No separate context display here anymore for edits */}
|
||||
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user