refactor(cli): replace slash command whitelist with capability-based filtering (Phase 1) (#3283)

* refactor(cli): replace slash command whitelist with capability-based filtering (Phase 1)

## Summary

Replace the hardcoded ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE whitelist with a
unified, capability-based command metadata model. This is Phase 1 of the slash
command architecture refactor described in docs/design/slash-command/.

## Key changes

### New types (types.ts)
- Add ExecutionMode ('interactive' | 'non_interactive' | 'acp')
- Add CommandSource ('builtin-command' | 'bundled-skill' | 'skill-dir-command' |
  'plugin-command' | 'mcp-prompt')
- Add CommandType ('prompt' | 'local' | 'local-jsx')
- Extend SlashCommand interface with: source, sourceLabel, commandType,
  supportedModes, userInvocable, modelInvocable, argumentHint, whenToUse,
  examples (all optional, backward-compatible)

### New module (commandUtils.ts + commandUtils.test.ts)
- getEffectiveSupportedModes(): 3-priority inference
  (explicit supportedModes > commandType > CommandKind fallback)
- filterCommandsForMode(): replaces filterCommandsForNonInteractive()
- 18 unit tests

### Whitelist removal (nonInteractiveCliCommands.ts)
- Remove ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE constant
- Remove filterCommandsForNonInteractive() function
- Replace with CommandService.getCommandsForMode(mode)

### CommandService enhancements (CommandService.ts)
- Add getCommandsForMode(mode: ExecutionMode): filters by mode, excludes hidden
- Add getModelInvocableCommands(): reserved for Phase 3 model tool-call use

### Built-in command annotations (41 files)
Annotate every built-in command with commandType:
- commandType='local' + supportedModes all-modes: btw, bug, compress, context,
  init, summary (replaces the 6-command whitelist)
- commandType='local' interactive-only: export, memory, plan, insight
- commandType='local-jsx' interactive-only: all remaining ~31 commands

### Loader metadata injection (4 files)
Each loader stamps source/sourceLabel/commandType/modelInvocable on every
command it emits:
- BuiltinCommandLoader: source='builtin-command', modelInvocable=false
- BundledSkillLoader: source='bundled-skill', commandType='prompt',
  modelInvocable=true
- command-factory (FileCommandLoader): source per extension/user origin,
  commandType='prompt', modelInvocable=!extensionName
- McpPromptLoader: source='mcp-prompt', commandType='prompt', modelInvocable=true

### Bug fix
MCP_PROMPT commands were incorrectly excluded from non-interactive/ACP modes by
the old whitelist logic. commandType='prompt' now correctly allows them in all
modes.

### Session.ts / nonInteractiveHelpers.ts
- ACP session calls getAvailableCommands with explicit 'acp' mode
- Remove allowedBuiltinCommandNames parameter from buildSystemMessage() —
  capability filtering is now self-contained in CommandService

* fix test ci

* fix memory command

* fix: pass 'non_interactive' mode explicitly to getAvailableCommands

- Fix critical bug in nonInteractiveHelpers.ts: loadSlashCommandNames was
  calling getAvailableCommands without specifying mode, causing it to default
  to 'acp' instead of 'non_interactive'. Commands with supportedModes that
  include 'non_interactive' but not 'acp' would be silently excluded.
- Apply the same fix in systemController.ts for the same reason.
- Update test mock to delegate filtering to production filterCommandsForMode()
  instead of duplicating the logic inline, preventing divergence.

Fixes review comments by wenshao and tanzhenxin on PR #3283.

* fix: resolve TypeScript type error in nonInteractiveHelpers.test.ts

* fix test ci
This commit is contained in:
顾盼 2026-04-20 14:34:43 +08:00 committed by GitHub
parent 6c999fe29f
commit a82d766727
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 2350 additions and 307 deletions

View file

@ -0,0 +1,671 @@
# Qwen Code Command 模块重构方案
## 1. 目标定义
本方案以以下原则为唯一前提:
- **代码架构可以不照搬 Claude Code**
- **但命令系统的核心功能、使用体验、交互体验必须 95% 对齐 Claude Code**
这里的“对齐”指用户可直接感知的能力,包括:
1. 命令来源覆盖
2. 命令帮助与发现性
3. 命令补全与 mid-input slash command 体验
4. ACP / non-interactive 可用性
5. prompt command / skill 的模型调用能力
本次重构不是补几个字段,也不是把现有 `SlashCommand` 小修小补,而是把 command 模块从“interactive UI 附属能力”升级为“跨 interactive / ACP / non-interactive / model 的统一命令平台”。
---
## 2. 重写后的结论
Qwen 现有 command 系统的问题,不是完全没有能力,而是:
1. 只在 interactive 主路径上较完整
2. 类型模型太薄,无法承载 Claude 级别的产品面
3. ACP / non-interactive 依赖白名单,扩展性极差
4. command 来源虽然存在,但没有形成对用户可见的统一心智
5. prompt command 与模型 skill 暴露体系割裂
因此新的方案必须同时解决四件事:
1. **补齐 Claude Code 的能力面**
2. **保留 Qwen 统一 outcome 模型的工程优势**
3. **建立统一 registry / resolver / executor / adapter 架构**
4. **让帮助、补全、ACP available commands、文档共用同一套元数据**
---
## 3. 重构原则
### 3.1 功能对齐优先于实现对齐
允许不同:
- 内部类名
- 模块拆分方式
- 执行器实现
- effect / outcome 结构
不允许不同:
- 命令来源覆盖明显缩水
- 命令帮助和补全体验明显缩水
- ACP / non-interactive 可用性明显缩水
- prompt command 与模型能力融合明显缩水
如果出现取舍,优先级应为:
1. 用户体验对齐
2. 命令能力覆盖对齐
3. 模式一致性对齐
4. 内部实现简洁
### 3.2 保留 Qwen 的统一 outcome 模型
不建议机械复制 Claude 的执行实现。
Qwen 当前统一结果模型仍然值得保留,因为它天然适合:
- UI 接管
- 审批/确认
- tool 调度
- prompt 提交
- 跨模式适配
但它必须被升级为能够承载 Claude 级别的 command 能力,而不是继续作为简化版 UI 命令框架存在。
### 3.3 类型、来源、模式、可见性必须彻底解耦
新的 command 模型至少要把以下维度拆开:
1. **类型**:命令怎么执行
2. **来源**:命令从哪里来
3. **模式能力**:在哪些运行环境可用
4. **可见性**:对用户可见还是对模型可见
---
## 4. 需要对齐的 Claude Code 能力面
### 4.1 命令类型
Qwen 需要显式支持三类命令:
1. `prompt`
2. `local`
3. `local-jsx`
### 4.2 命令来源
Qwen 的 command schema 从第一阶段开始就必须覆盖以下来源:
1. built-in commands
2. bundled skills
3. skill dir commands
4. workflow commands
5. plugin commands
6. plugin skills
7. dynamic skills
8. mcp prompts
9. mcp skills
这里不能再退回到“先只支持当前已有那几类”。
### 4.3 命令元数据
至少补齐以下字段:
1. `argumentHint`
2. `whenToUse`
3. `examples`
4. `sourceLabel`
5. `userFacingName`
6. `alias`
7. `immediate`
8. `isSensitive`
9. `userInvocable`
10. `modelInvocable`
11. `supportedModes`
12. `requiresUi`
### 4.4 体验能力
至少补齐以下体验:
1. alias 命中补全
2. source badge
3. 参数提示
4. recently used 排序
5. mid-input slash command 检测与补全
6. 命令目录式 Help
7. ACP available commands 的完整表达
---
## 5. 新 command 模型
## 5.1 核心结构
建议引入统一 `CommandDescriptor`,作为所有命令的注册格式。
它至少包含四部分:
1. `identity`
2. `metadata`
3. `capabilities`
4. `handler`
### `identity`
- `id`
- `name`
- `altNames`
- `canonicalPath`
### `metadata`
- `description`
- `argumentHint`
- `whenToUse`
- `examples`
- `group`
- `source`
- `sourceLabel`
- `userFacingName`
- `hidden`
### `capabilities`
- `type`: `prompt | local | local-jsx`
- `supportedModes`: `interactive | acp | non_interactive`
- `requiresUi`
- `supportsDialog`
- `supportsStreaming`
- `supportsToolInvocation`
- `supportsConfirmation`
- `remoteSafe`
- `readOnly`
- `immediate`
- `isSensitive`
- `userInvocable`
- `modelInvocable`
### `handler`
- `resolveArgs()`
- `execute()`
- `completion()`
- `fallback()`
---
## 5.2 三种命令类型的职责
### `prompt`
用于:
- skills
- file commands
- workflow prompt commands
- plugin skills
- mcp prompt / skill
特点:
- 产生 prompt / skill 资产
- 默认支持 interactive / ACP / non-interactive
- 可以被用户调用,也可以被模型调用
### `local`
用于:
- 查询类命令
- 配置类命令
- headless 可执行的状态类命令
- 大多数 built-in commands 的核心执行入口
特点:
- 不依赖 UI
- 应成为 ACP / non-interactive 的主承载类型
### `local-jsx`
用于:
- picker
- 面板
- wizard
- interactive UI shell
特点:
- 只处理 interactive UI
- 不能再作为唯一执行入口
- 必须提供 fallback 或对应 local 子命令
---
## 6. 命令来源模型
## 6.1 外部来源模型
这是给用户看的来源模型,必须和 Claude Code 的心智尽量一致:
- `builtin-command`
- `bundled-skill`
- `skill-dir-command`
- `workflow-command`
- `plugin-command`
- `plugin-skill`
- `dynamic-skill`
- `builtin-plugin-skill`
- `mcp-prompt`
- `mcp-skill`
这组字段将直接用于:
- Help 分组
- Completion source badge
- ACP available commands
- 文档导出
## 6.2 内部归一化模型
为了不被外部命名绑死,内部再补一层实现字段:
- `providerType`
- `artifactType`
- `activationMode`
- `builtinProvided`
- `originPath`
- `namespace`
这样可以做到:
- 外部体验按 Claude 对齐
- 内部实现仍保持 Qwen 可维护性
## 6.3 冲突策略
统一按稳定 `id` 管理,展示名和输入名分离:
1. `id`:稳定唯一标识
2. `name`:输入主名
3. `userFacingName`:帮助/补全展示名
冲突优先级建议:
1. built-in
2. bundled / skill-dir / workflow
3. plugin / builtin-plugin
4. dynamic
5. mcp 独立 namespace
---
## 7. 统一执行架构
## 7.1 `CommandRegistry`
职责:
1. 聚合所有 loader/provider
2. 建立多维索引
3. 输出帮助、补全、ACP、文档视图
4. 提供用户可见命令和模型可见命令的独立视图
必须支持的 provider
1. `BuiltinCommandLoader`
2. `BundledSkillLoader`
3. `FileCommandLoader`
4. `McpPromptLoader`
5. `WorkflowCommandLoader`
6. `PluginCommandLoader`
7. `PluginSkillLoader`
8. `DynamicSkillProvider`
9. `BuiltinPluginSkillLoader`
即便部分 provider 首期未完全落地schema 和 API 也必须先支持。
## 7.2 `CommandResolver`
职责:
1. 解析 slash command
2. 解析 alias
3. 解析 subcommand path
4. 识别 mid-input slash token
5. 输出 canonical resolved command
## 7.3 `CommandExecutor`
职责:
1. 做 capability 检查
2. 执行 `prompt | local | local-jsx`
3. 统一产出 outcome
4. 处理 fallback / unsupported
## 7.4 `ModeAdapter`
必须拆出三种 adapter
1. `InteractiveModeAdapter`
2. `AcpModeAdapter`
3. `NonInteractiveModeAdapter`
这样三种模式才能共用同一套 command registry 和 executor而不是各自硬编码。
---
## 8. UI 命令重构原则:核心命令与交互壳分离
这是 ACP 和 non-interactive 真正可用的关键。
凡是当前本质为“打开 dialog”的命令都必须改造成
1. 一个 interactive shell
2. 一组 local 子命令
### 第一批必须拆分的命令
1. `/model`
2. `/permissions`
3. `/mcp`
4. `/resume`
5. `/hooks`
6. `/extensions`
7. `/agents`
8. `/approval-mode`
### 目标形态示例
#### `/model`
- `/model`
- `/model show`
- `/model list`
- `/model set <id>`
#### `/permissions`
- `/permissions`
- `/permissions show`
- `/permissions set <mode>`
- `/permissions allow <tool>`
- `/permissions deny <tool>`
#### `/mcp`
- `/mcp`
- `/mcp list`
- `/mcp show <server>`
- `/mcp enable <server>`
- `/mcp disable <server>`
---
## 9. Prompt Command / Skill 统一设计
这是重构里的 P0不是后补能力。
## 9.1 目标
建立统一的 **Model-Invocable Prompt Command Registry**,把以下资产合并为一个模型可调用视图:
1. bundled skills
2. file commands
3. workflow prompt commands
4. plugin skills
5. mcp prompts / mcp skills
## 9.2 关键字段
必须新增:
1. `userInvocable`
2. `modelInvocable`
3. `allowedTools`
4. `whenToUse`
5. `argSchema` 或最小参数描述
6. `contextMode: inline | fork`
7. `agent`
8. `effort`
## 9.3 与 `SkillTool` 的关系
重构后不应再由 `SkillTool` 只消费狭义 skills。
应改成:
1. `CommandRegistry.getModelInvocablePromptCommands()` 产出统一视图
2. `SkillTool` 或未来统一 command tool 消费该视图
3. 用户 slash command 与模型 skill invocation 共用同一套 prompt-command 资产池
这样 Qwen 才能在体验上接近 Claude 对 `/review``/commit``/openspec-apply` 这类能力的处理方式。
---
## 10. Help / Completion / Discoverability 重做
## 10.1 Completion
补全项至少要展示:
1. `label`
2. `description`
3. `argumentHint`
4. `sourceBadge`
5. `modeBadges`
6. `aliasHit`
7. `recentlyUsedScore`
排序至少考虑:
1. 精确命中
2. alias 命中
3. 最近使用
4. prefix 命中
5. fuzzy 命中
## 10.2 Mid-input slash command
必须补齐:
1. 光标附近 slash token 检测
2. ghost text 提示
3. Tab 完成
4. 有效命令 token 高亮
第一阶段先对齐输入体验;是否引入更强的“内嵌命令执行语义”可在后续迭代。
## 10.3 Help
Help 不再是平铺列表,而是完整命令目录。
至少分组为:
1. Built-in Commands
2. Bundled Skills
3. Skill Dir Commands
4. Workflow Commands
5. Plugin Commands
6. Plugin Skills
7. Dynamic Skills
8. Builtin Plugin Skills
9. MCP Commands / MCP Skills
每条命令至少展示:
1. 名称
2. 参数提示
3. 描述
4. 来源
5. 支持模式
6. 是否模型可调用
7. 子命令摘要
---
## 11. ACP / Non-Interactive 重构
## 11.1 彻底废弃白名单思路
旧方案:
- built-in allowlist
- FILE / SKILL 特判
- 其它结果类型 unsupported
新方案:
- 每个命令自己声明 capability
- registry 负责过滤
- adapter 负责执行和 fallback
## 11.2 outcome 支持目标
### interactive
- `submit_prompt`
- `message`
- `stream_messages`
- `tool`
- `dialog`
- `load_history`
- `confirm_action`
- `confirm_shell_commands`
### acp
- `submit_prompt`
- `message`
- `stream_messages`
- `tool`
- `confirm_action`
- `confirm_shell_commands`
- `dialog fallback`
### non_interactive
- `submit_prompt`
- `message`
- `stream_messages`
- `tool`
- `confirm_action`
- `confirm_shell_commands`
- `dialog fallback / structured failure`
## 11.3 ACP available commands 输出
必须至少包含:
1. `name`
2. `description`
3. `argumentHint`
4. `source`
5. `examples`
6. `supportedModes`
7. `interactiveOnly`
8. `subcommands`
9. `modelInvocable`
---
## 12. 文档、帮助、补全共用同一份元数据
重构后以下内容必须由同一个 registry 视图导出:
1. Help
2. Completion
3. ACP available commands
4. 文档导出
这是为了解决当前“实现、帮助、文档三套命令面不一致”的问题。
---
## 13. 实施分期
## Phase 1底座重建
交付:
1. 新 `CommandDescriptor`
2. 完整来源 schema
3. capability 模型
4. `userInvocable / modelInvocable`
5. `CommandRegistry`
6. `CommandResolver`
7. `CommandExecutor`
8. 三种 `ModeAdapter`
9. `getModelInvocablePromptCommands()`
## Phase 2核心命令迁移
交付:
1. `/model`
2. `/permissions`
3. `/mcp`
4. `/resume`
5. `/hooks`
6. `/extensions`
7. `/agents`
8. `/approval-mode`
这些命令都必须完成“interactive shell + local 子命令”重构。
## Phase 3模型能力打通
交付:
1. `SkillTool` 接入统一 registry 视图
2. file command / bundled skill / mcp prompt / plugin skill 进入统一 model-invocable 集合
3. prompt command 与 skill 资产彻底统一
## Phase 4体验层对齐 Claude
交付:
1. recently used 排序
2. source badge
3. argument hint
4. mode badge
5. 完整 help 目录
6. mid-input slash command 体验
7. 文档自动导出或校验
---
## 14. 验收标准
完成后至少满足:
1. 帮助、补全、ACP、文档都能表达完整来源模型
2. 除纯 UI 壳命令外,大多数 built-in command 可在 ACP / non-interactive 使用
3. prompt command 与模型 skill 调用使用同一套资产池
4. 命令体验在帮助、补全、来源表达、参数提示、mid-input 体验上达到 Claude Code 95% 水平
5. 不再依赖 built-in allowlist 维持 ACP / non-interactive 命令能力
---
## 15. 最终判断
这次重构的本质不是“给现有 SlashCommand 多加几个字段”,而是:
- **用 Qwen 的内部架构风格,交付一个在外部体验上 95% 对齐 Claude Code 的 command 平台**
如果必须二选一:
- 内部实现更像 Claude
- 外部体验更像 Claude
本方案明确选择后者。

View file

@ -0,0 +1,765 @@
# Phase 1 技术设计文档:基础设施重建
## 1. 设计目标与约束
### 1.1 目标
- 建立统一的命令元数据模型覆盖来源source、执行类型commandType、模式能力supportedModes、可见性userInvocable / modelInvocable四个维度
- 用 capability-based 过滤替换 non-interactive/acp 中的硬编码白名单
- 为 Phase 2/3 的能力扩展提供稳定的底层接口
### 1.2 硬性约束
- **零行为变化**non-interactive 和 acp 模式下现有可用命令集保持不变(例外:修复 MCP_PROMPT 被错误拦截,属于 bug fix
- **向后兼容**`SlashCommand` 接口的新增字段全部为可选或有合理默认值,现有命令代码无需立即修改
- **不新增执行器**:不创建 ModeAdapter / CommandExecutor 等新执行架构,只扩展现有 CommandService 和过滤逻辑
- **不改变现有命令能力**:不为任何命令新增 local 子命令,不修改任何命令的 action 实现
---
## 2. 新增类型定义
### 2.1 文件位置
所有新增类型定义在 `packages/cli/src/ui/commands/types.ts`,与现有 `SlashCommand` 接口共文件。
### 2.2 `ExecutionMode`
```typescript
/**
* 运行模式枚举。
* - interactiveReact/Ink UI 模式(终端交互)
* - non_interactive无交互 CLI 模式(文本/JSON 输出)
* - acpACP/Zed 集成模式
*/
export type ExecutionMode = 'interactive' | 'non_interactive' | 'acp';
```
### 2.3 `CommandSource`
```typescript
/**
* 命令来源枚举,用于 Help 分组、补全 badge、ACP available commands。
*
* 与 CommandKind 的区别:
* - CommandKind 是内部加载器分类4 种),影响加载逻辑
* - CommandSource 是面向用户的来源分类9 种),影响展示和心智模型
*
* 两者可能重叠,但职责不同,不合并。
*/
export type CommandSource =
| 'builtin-command' // 内置命令BuiltinCommandLoader
| 'bundled-skill' // 随包分发的 skillBundledSkillLoader
| 'skill-dir-command' // 用户/项目 .qwen/commands/ 下的文件命令FileCommandLoader非插件
| 'plugin-command' // 插件提供的命令FileCommandLoaderextensionName 不为空)
| 'mcp-prompt'; // MCP server 提供的 promptMcpPromptLoader
// 以下来源预留Phase 1 不实现对应 Loader但 schema 先定义:
// | 'workflow-command'
// | 'plugin-skill'
// | 'dynamic-skill'
// | 'builtin-plugin-skill'
// | 'mcp-skill'
```
### 2.4 `CommandType`
```typescript
/**
* 命令执行类型,描述命令"怎么执行"。
*
* - prompt产生 submit_prompt将内容提交给模型。适用于 skill、file command、MCP prompt。
* 默认 supportedModes 为所有模式,默认 modelInvocable 为 true。
*
* - local在本地执行逻辑不依赖 React/Ink UI。可返回 message、stream_messages、
* submit_prompt、tool 等类型。适用于查询类、配置类、状态类 built-in 命令。
* 默认 supportedModes 为 ['interactive'],需显式声明 supportedModes 才能开放给其他模式。
* 这与 Claude Code 的 supportsNonInteractive: true 语义一致——非交互支持需要显式声明,而非自动推断。
*
* - local-jsx依赖 React/Ink UI 的命令(打开 dialog、渲染 JSX 组件等)。
* 默认 supportedModes 仅为 ['interactive']。
*/
export type CommandType = 'prompt' | 'local' | 'local-jsx';
```
### 2.5 扩展 `SlashCommand` 接口
在现有接口上追加新字段,**全部为可选**以保证向后兼容:
```typescript
export interface SlashCommand {
// ── 现有字段(保持不变) ──────────────────────────────────────────────
name: string;
altNames?: string[];
description: string;
hidden?: boolean;
completionPriority?: number;
kind: CommandKind;
extensionName?: string;
action?: (...) => ...;
completion?: (...) => ...;
subCommands?: SlashCommand[];
// ── Phase 1 新增:来源与执行类型 ──────────────────────────────────────
/**
* 命令来源,用于 Help 分组、补全 badge、ACP available commands 展示。
* 由各 Loader 填充,不由命令自身声明。
* 未来废弃 CommandKind 时source 将成为唯一来源标识。
*/
source?: CommandSource;
/**
* 展示用的来源标签,面向用户。
* - builtin-command → "Built-in"
* - bundled-skill → "Skill"
* - skill-dir-command → "Custom"
* - plugin-command → "Plugin: <extensionName>"
* - mcp-prompt → "MCP: <serverName>"
* 由各 Loader 填充,可被命令自身覆盖。
*/
sourceLabel?: string;
/**
* 命令执行类型。
* - 由各 Loader 填充默认值prompt/local-jsx
* - built-in 命令由各命令文件自身声明local 或 local-jsx
* 未声明时的默认策略见 getEffectiveCommandType()。
*/
commandType?: CommandType;
// ── Phase 1 新增:模式能力 ──────────────────────────────────────────
/**
* 此命令在哪些运行模式下可用。
* 未声明时根据 commandType 推断默认值(见 getEffectiveSupportedModes())。
* 显式声明优先于推断值。
*/
supportedModes?: ExecutionMode[];
// ── Phase 1 新增:可见性 ──────────────────────────────────────────────
/**
* 用户是否可通过 slash command 调用此命令。
* 默认 true几乎所有命令都是 userInvocable
*/
userInvocable?: boolean;
/**
* 模型是否可通过 tool call 调用此命令。
* 默认 false。prompt 类型的命令skill、file command、MCP prompt应设为 true。
* built-in commands 不允许模型调用(始终为 false
*/
modelInvocable?: boolean;
// ── Phase 3 预留体验元数据Phase 1 仅定义,不使用)──────────────────
/**
* 参数提示,显示在补全菜单命令名后。
* 示例:"<model-id>" / "show|list|set <id>" / "[--fast] [<model-id>]"
*/
argumentHint?: string;
/**
* 供模型理解何时调用此命令的说明。
* 将被注入 modelInvocable 命令的 description 中。
*/
whenToUse?: string;
/**
* 使用示例,供 Help 目录和补全展示。
*/
examples?: string[];
}
```
---
## 3. 各 Loader 的字段填充规范
### 3.1 填充原则
- `source``sourceLabel` 由 Loader 在构建 `SlashCommand` 时填充,命令自身不声明
- `commandType`Loader 填充默认值built-in 命令由命令文件自身声明
- `supportedModes`:通过 `getEffectiveSupportedModes()` 推断,不需要显式填充(除非需要覆盖默认值)
- `modelInvocable`Loader 填充built-in 命令始终为 `false`prompt 类型命令为 `true`
### 3.2 `BuiltinCommandLoader`
```typescript
// 不填充 source/sourceLabel/commandType — 由各命令文件自声明
// 因为 built-in 命令的 commandType 是 local 或 local-jsx需要逐个标注
// 注入 source 和 sourceLabel
for (const cmd of rawCommands) {
enrichedCommands.push({
...cmd,
source: 'builtin-command',
sourceLabel: 'Built-in',
userInvocable: cmd.userInvocable ?? true,
modelInvocable: false, // built-in 命令不允许模型调用
});
}
```
### 3.3 `BundledSkillLoader`
```typescript
return skills.map((skill) => ({
name: skill.name,
description: skill.description,
kind: CommandKind.SKILL,
source: 'bundled-skill' as CommandSource,
sourceLabel: 'Skill',
commandType: 'prompt' as CommandType,
userInvocable: true,
modelInvocable: true,
action: async (...) => { ... },
}));
```
### 3.4 `FileCommandLoader`
```typescript
// 在 createSlashCommandFromDefinition 中:
return {
name: baseCommandName,
description,
kind: CommandKind.FILE,
extensionName,
// source 根据 extensionName 决定:
source: extensionName ? 'plugin-command' : 'skill-dir-command',
sourceLabel: extensionName ? `Plugin: ${extensionName}` : 'Custom',
commandType: 'prompt',
userInvocable: true,
modelInvocable: !extensionName, // 插件命令暂不允许模型调用,用户/项目命令允许
action: async (...) => { ... },
};
```
> **注**插件命令plugin-command暂不标记为 `modelInvocable`,避免安全隐患。后续 Phase 可以按需开放,由用户通过配置控制。
### 3.5 `McpPromptLoader`
```typescript
const newPromptCommand: SlashCommand = {
name: commandName,
description: prompt.description || `Invoke prompt ${prompt.name}`,
kind: CommandKind.MCP_PROMPT,
source: 'mcp-prompt',
sourceLabel: `MCP: ${serverName}`,
commandType: 'prompt',
userInvocable: true,
modelInvocable: true,
// ... 其余现有字段
};
```
---
## 4. Built-in 命令的 `commandType` 声明规范
### 4.1 分类标准
| commandType | 判断标准 |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `local` | action 只使用 `ui.addItem`(文本类型)、返回 `message` / `stream_messages` / `submit_prompt` / `tool`,不依赖 React 组件渲染 |
| `local-jsx` | action 返回 `dialog`,或 action 中调用 `ui.addItem` 时传入含 JSX 的复杂类型(如 `HistoryItemHelp``HistoryItemStats`),或依赖 `confirm_action` / `load_history` / `quit` |
> **注意**`ui.addItem(message/error/info 类型)``local``ui.addItem(help/stats/tools/about 等复杂 UI 类型)``local-jsx`
### 4.2 Built-in 命令分类表
**`local` 类**(声明 `commandType: 'local'``supportedModes` 推断为 all modes
| 命令文件 | 命令名 | 说明 |
| -------------------- | ---------- | ------------------------------------------------------- |
| `btwCommand.ts` | `btw` | 返回 `submit_prompt``stream_messages` |
| `bugCommand.ts` | `bug` | 返回 `submit_prompt``stream_messages` |
| `compressCommand.ts` | `compress` | 已有 executionMode 适配,返回 `message`/`submit_prompt` |
| `contextCommand.ts` | `context` | 返回 `message`(含 UI 渲染但文本可替代) |
| `exportCommand.ts` | `export` | 文件 I/O返回 `message` |
| `initCommand.ts` | `init` | 返回 `submit_prompt`/`message`/`confirm_action` |
| `memoryCommand.ts` | `memory` | 子命令返回 `message`(文件 I/O |
| `planCommand.ts` | `plan` | 返回 `submit_prompt` |
| `summaryCommand.ts` | `summary` | 已有 executionMode 适配,返回 `submit_prompt`/`message` |
| `insightCommand.ts` | `insight` | 返回 `stream_messages` |
> **注意**`contextCommand``insightCommand` 虽然当前返回 `addItem` 调用,但其本质是文本内容,属于 `local`
**`local-jsx` 类**(声明 `commandType: 'local-jsx'``supportedModes` 推断为 `['interactive']`
| 命令文件 | 命令名 | 不能 headless 的原因 |
| ------------------------- | ---------------- | ------------------------------------------ |
| `aboutCommand.ts` | `about` | `addItem(HistoryItemAbout)` — 复杂 UI 组件 |
| `agentsCommand.ts` | `agents` | `dialog: subagent_create/subagent_list` |
| `approvalModeCommand.ts` | `approval-mode` | `dialog: approval-mode` |
| `arenaCommand.ts` | `arena` | `dialog: arena_*` |
| `authCommand.ts` | `auth` | `dialog: auth` |
| `clearCommand.ts` | `clear` | `ui.clear()` 直接操作终端 |
| `copyCommand.ts` | `copy` | 剪贴板操作,无 headless 路径 |
| `directoryCommand.tsx` | `directory` | JSX 组件 |
| `docsCommand.ts` | `docs` | 打开浏览器 |
| `editorCommand.ts` | `editor` | `dialog: editor` |
| `extensionsCommand.ts` | `extensions` | `dialog: extensions_manage` |
| `helpCommand.ts` | `help` | `addItem(HistoryItemHelp)` — 复杂 Help UI |
| `hooksCommand.ts` | `hooks` | `dialog: hooks` |
| `ideCommand.ts` | `ide` | IDE 进程检测与交互 |
| `languageCommand.ts` | `language` | `dialog` + `reloadCommands` |
| `mcpCommand.ts` | `mcp` | `dialog: mcp` |
| `modelCommand.ts` | `model` | `dialog: model/fast-model` |
| `permissionsCommand.ts` | `permissions` | `dialog: permissions` |
| `quitCommand.ts` | `quit` | `quit` result 类型 |
| `restoreCommand.ts` | `restore` | `load_history` result 类型 |
| `resumeCommand.ts` | `resume` | `dialog: resume` |
| `settingsCommand.ts` | `settings` | `dialog: settings` |
| `setupGithubCommand.ts` | `setup-github` | `confirm_shell_commands` + 交互式操作 |
| `skillsCommand.ts` | `skills` | `addItem(HistoryItemSkillsList)` — 复杂 UI |
| `statsCommand.ts` | `stats` | `addItem(HistoryItemStats)` — 复杂 UI |
| `statuslineCommand.ts` | `statusline` | UI 状态配置 |
| `terminalSetupCommand.ts` | `terminal-setup` | 终端配置向导 |
| `themeCommand.ts` | `theme` | `dialog: theme` |
| `toolsCommand.ts` | `tools` | `addItem(HistoryItemTools)` — 复杂 UI |
| `trustCommand.ts` | `trust` | `dialog: trust` |
| `vimCommand.ts` | `vim` | `toggleVimEnabled()` — UI 状态 |
---
## 5. `getEffectiveSupportedModes` 推断规则
此函数是 Phase 1 的核心逻辑,替代原有白名单,将被 `filterCommandsForMode` 调用。
```typescript
/**
* 获取命令的实际支持模式列表。
*
* 推断优先级(从高到低):
* 1. 命令显式声明的 supportedModes最高优先级
* 2. 基于 commandType 的推断
* 3. 基于 CommandKind 的兜底(向后兼容)
*/
export function getEffectiveSupportedModes(cmd: SlashCommand): ExecutionMode[] {
// 优先级 1显式声明
if (cmd.supportedModes !== undefined) {
return cmd.supportedModes;
}
// 优先级 2基于 commandType 推断
if (cmd.commandType !== undefined) {
switch (cmd.commandType) {
case 'prompt':
// prompt 类型无 UI 依赖,天然全模式可用
return ['interactive', 'non_interactive', 'acp'];
case 'local':
// local 类型保守默认:仅 interactive。
// 需要非交互支持的命令须显式声明 supportedModes对应 Claude Code 的 supportsNonInteractive: true
// Phase 2 中逐个验证并解锁,防止未适配的命令意外暴露给 headless 调用者。
return ['interactive'];
case 'local-jsx':
return ['interactive'];
}
}
// 优先级 3兜底基于 CommandKind向后兼容旧代码
switch (cmd.kind) {
case CommandKind.BUILT_IN:
// built-in 命令未声明 commandType 时保守默认interactive only
// 这个分支在 Phase 1 完成后应不再被命中(所有 built-in 都有 commandType
return ['interactive'];
case CommandKind.FILE:
case CommandKind.SKILL:
case CommandKind.MCP_PROMPT:
// 这三类命令的 action 天然无 UI 依赖,历史行为也是全模式可用
return ['interactive', 'non_interactive', 'acp'];
default:
return ['interactive'];
}
}
```
```typescript
/**
* 根据 supportedModes 过滤适合当前模式的命令。
* 替代原 filterCommandsForNonInteractive 函数。
*/
export function filterCommandsForMode(
commands: readonly SlashCommand[],
mode: ExecutionMode,
): SlashCommand[] {
return commands.filter((cmd) =>
getEffectiveSupportedModes(cmd).includes(mode),
);
}
```
---
## 6. `CommandService` 接口扩展
`packages/cli/src/services/CommandService.ts` 中新增两个方法:
```typescript
export class CommandService {
// ── 现有方法(保持不变)────────────────────────────────────────────────
getCommands(): readonly SlashCommand[] {
return this.commands;
}
// ── Phase 1 新增方法 ──────────────────────────────────────────────────
/**
* 返回在指定执行模式下可用的命令列表。
* 替代原有白名单 + filterCommandsForNonInteractive 的组合。
*
* @param mode 目标运行模式
* @returns 适合该模式的命令列表(不含 hidden 命令)
*/
getCommandsForMode(mode: ExecutionMode): readonly SlashCommand[] {
return this.commands.filter((cmd) => {
if (cmd.hidden) return false;
return getEffectiveSupportedModes(cmd).includes(mode);
});
}
/**
* 返回所有 modelInvocable 为 true 的命令。
* Phase 2 中 SkillTool 将消费此方法Phase 1 仅提供接口。
*
* @returns 模型可调用的命令列表
*/
getModelInvocableCommands(): readonly SlashCommand[] {
return this.commands.filter(
(cmd) => !cmd.hidden && cmd.modelInvocable === true,
);
}
}
```
> **注意**`getEffectiveSupportedModes``filterCommandsForMode` 应作为 `CommandService` 内部使用的工具函数,或提取到独立的 `packages/cli/src/services/commandUtils.ts` 文件并导出,以便测试和复用。
---
## 7. `nonInteractiveCliCommands.ts` 重构
### 7.1 删除内容
```typescript
// ❌ 删除
export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [
'init', 'summary', 'compress', 'btw', 'bug', 'context',
] as const;
// ❌ 删除
function filterCommandsForNonInteractive(
commands: readonly SlashCommand[],
allowedBuiltinCommandNames: Set<string>,
): SlashCommand[] { ... }
```
### 7.2 新增内容
```typescript
// ✅ 新增(或从 commandUtils 导入)
import { filterCommandsForMode } from '../services/commandUtils.js';
```
### 7.3 `handleSlashCommand` 函数签名变更
```typescript
// ❌ 旧签名
export const handleSlashCommand = async (
rawQuery: string,
abortController: AbortController,
config: Config,
settings: LoadedSettings,
allowedBuiltinCommandNames: string[] = [...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE],
): Promise<NonInteractiveSlashCommandResult>
// ✅ 新签名(移除 allowedBuiltinCommandNames
export const handleSlashCommand = async (
rawQuery: string,
abortController: AbortController,
config: Config,
settings: LoadedSettings,
): Promise<NonInteractiveSlashCommandResult>
```
### 7.4 内部实现变更
```typescript
// 旧:
const filteredCommands = filterCommandsForNonInteractive(
allCommands,
allowedBuiltinSet,
);
// 新:
const executionMode = isAcpMode ? 'acp' : 'non_interactive';
const filteredCommands = filterCommandsForMode(allCommands, executionMode);
```
### 7.5 `getAvailableCommands` 函数签名变更
```typescript
// ❌ 旧签名
export const getAvailableCommands = async (
config: Config,
abortSignal: AbortSignal,
allowedBuiltinCommandNames: string[] = [...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE],
): Promise<SlashCommand[]>
// ✅ 新签名
export const getAvailableCommands = async (
config: Config,
abortSignal: AbortSignal,
mode: ExecutionMode = 'acp',
): Promise<SlashCommand[]>
```
> 新增 `mode` 参数替代原来的白名单参数ACP Session 调用时可明确指定 `'acp'`non-interactive 调用时指定 `'non_interactive'`
---
## 8. `Session.ts`ACP调用变更
```typescript
// ❌ 旧调用
const slashCommandResult = await handleSlashCommand(
inputText,
abortController,
this.config,
this.settings,
// 不传,使用默认白名单
);
// ✅ 新调用(无变化,移除了不再存在的默认参数)
const slashCommandResult = await handleSlashCommand(
inputText,
abortController,
this.config,
this.settings,
);
// ─────────────────────────────────────────
// ❌ 旧调用
const slashCommands = await getAvailableCommands(
this.config,
abortController.signal,
);
// ✅ 新调用(明确指定 mode
const slashCommands = await getAvailableCommands(
this.config,
abortController.signal,
'acp',
);
```
---
## 9. 文件变更总览
### 9.1 修改的文件
| 文件 | 修改内容 |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `packages/cli/src/ui/commands/types.ts` | 新增 `ExecutionMode``CommandSource``CommandType` 类型;扩展 `SlashCommand` 接口 |
| `packages/cli/src/services/CommandService.ts` | 新增 `getCommandsForMode()``getModelInvocableCommands()` 方法 |
| `packages/cli/src/nonInteractiveCliCommands.ts` | 删除白名单常量和旧过滤函数;更新两个导出函数的签名;引入 `filterCommandsForMode` |
| `packages/cli/src/acp-integration/session/Session.ts` | 更新 `handleSlashCommand``getAvailableCommands` 调用 |
| `packages/cli/src/services/BuiltinCommandLoader.ts` | 在构建命令时注入 `source: 'builtin-command'``sourceLabel: 'Built-in'``modelInvocable: false` |
| `packages/cli/src/services/BundledSkillLoader.ts` | 注入 `source: 'bundled-skill'``commandType: 'prompt'``modelInvocable: true` |
| `packages/cli/src/services/FileCommandLoader.ts` / `command-factory.ts` | 注入 `source``commandType: 'prompt'``modelInvocable`(根据 extensionName |
| `packages/cli/src/services/McpPromptLoader.ts` | 注入 `source: 'mcp-prompt'``commandType: 'prompt'``modelInvocable: true` |
| **各 built-in 命令文件10 个 local + 27 个 local-jsx** | 声明 `commandType: 'local'``commandType: 'local-jsx'` |
### 9.2 新增的文件
| 文件 | 内容 |
| ------------------------------------------- | -------------------------------------------------------------------------- |
| `packages/cli/src/services/commandUtils.ts` | `getEffectiveSupportedModes()``filterCommandsForMode()` 工具函数及其导出 |
### 9.3 不变的文件
- `packages/cli/src/utils/commands.ts``parseSlashCommand` 无需修改)
- `packages/cli/src/ui/hooks/slashCommandProcessor.ts`interactive 路径无需修改)
- `packages/cli/src/ui/noninteractive/nonInteractiveUi.ts`stub UI 无需修改)
- 所有命令的 `action` 实现Phase 1 不修改任何命令行为)
---
## 10. 行为影响分析
### 10.1 变化汇总
| 场景 | 旧行为 | 新行为 | 性质 |
| ------------------------------------ | ---------------------------- | -------------------------------------------------------- | ----------- |
| non-interactive 下执行 `/init` | ✅ 允许(白名单) | ✅ 允许(`commandType: local` | 无变化 |
| non-interactive 下执行 `/summary` | ✅ 允许 | ✅ 允许 | 无变化 |
| non-interactive 下执行 `/compress` | ✅ 允许 | ✅ 允许 | 无变化 |
| non-interactive 下执行 `/btw` | ✅ 允许 | ✅ 允许 | 无变化 |
| non-interactive 下执行 `/bug` | ✅ 允许 | ✅ 允许 | 无变化 |
| non-interactive 下执行 `/context` | ✅ 允许 | ✅ 允许 | 无变化 |
| non-interactive 下执行 `/model` | ❌ unsupported | ❌ unsupported`commandType: local-jsx` | 无变化 |
| non-interactive 下执行 file command | ✅ 允许CommandKind.FILE | ✅ 允许(`commandType: prompt` | 无变化 |
| non-interactive 下执行 bundled skill | ✅ 允许CommandKind.SKILL | ✅ 允许(`commandType: prompt` | 无变化 |
| non-interactive 下执行 MCP prompt | ❌ 被 CommandKind 拦截 | ✅ 允许(`commandType: prompt` | **Bug fix** |
| non-interactive 下执行 `/export` | ❌ 不在白名单 | ❌ 不允许(`commandType: local`,默认 interactive only | 无变化 |
| non-interactive 下执行 `/memory` | ❌ 不在白名单 | ❌ 不允许(`commandType: local`,默认 interactive only | 无变化 |
| non-interactive 下执行 `/plan` | ❌ 不在白名单 | ❌ 不允许(`commandType: local`,默认 interactive only | 无变化 |
> **关于 `local` 命令的保守默认策略**`commandType: 'local'` 的默认 `supportedModes``['interactive']`,这与 Claude Code 的设计一致——`local` 类型命令需要显式声明 `supportsNonInteractive: true` 才能在非交互模式下运行。Phase 1 中白名单内的 6 个命令(`init``summary``compress``btw``bug``context`)通过显式声明 `supportedModes: ['interactive', 'non_interactive', 'acp']` 来等价替换原白名单效果。Phase 2 中需要扩展的命令(如 `/export``/memory``/plan`)在验证 action 实现 headless-friendly 之后,再逐个解锁。
---
## 10.2 Phase 2 模式差异命令:双注册模式
对于 Phase 2 中需要"交互模式有 UI非交互模式有文本输出"的命令(如 `/model`),应采用 **双注册模式**,而非在单个命令的 `action` 内部分支。
这是 Claude Code 的标准模式,以 `/context` 为例(参见 `src/commands/context/index.ts`):两个同名 `Command` 对象,一个 `local-jsx` 仅 interactive另一个 `local` 仅 non-interactive通过 `isEnabled()` 互斥。
Qwen Code 在 Phase 2 中应采用等价方式,以 `supportedModes` 替代 `isEnabled()` 实现互斥:
```typescript
// ① 交互模式版local-jsx仅 interactive
export const modelCommandInteractive: SlashCommand = {
name: 'model',
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
supportedModes: ['interactive'], // 显式限定
// action: 打开 dialog 选择 model
};
// ② 非交互/acp 版local显式开放给 headless 调用者
export const modelCommandHeadless: SlashCommand = {
name: 'model',
kind: CommandKind.BUILT_IN,
commandType: 'local',
supportedModes: ['non_interactive', 'acp'], // 显式限定
// action: 读取/设置 model返回 message纯文本
};
```
两个对象同名,`supportedModes` 互斥,`filterCommandsForMode` 自动选择正确版本。与 Claude Code 的 `isEnabled()` 互斥相比,`supportedModes` 过滤更显式、更易测试,且不需要运行时环境检测。
**Phase 1 不实现任何双注册命令**,该模式仅作为 Phase 2 的实施规范预留在此。
---
## 11. 测试策略
### 11.1 新增工具函数测试
`packages/cli/src/services/commandUtils.test.ts`(新文件)中:
```typescript
describe('getEffectiveSupportedModes', () => {
it('显式 supportedModes 优先于 commandType 推断', () => {
const cmd: SlashCommand = {
name: 'test', description: '', kind: CommandKind.BUILT_IN,
commandType: 'local',
supportedModes: ['interactive'], // 显式限制
};
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
});
it('commandType: local 推断为 all modes', () => {
const cmd: SlashCommand = { name: 'test', description: '', kind: CommandKind.BUILT_IN, commandType: 'local' };
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive', 'non_interactive', 'acp']);
});
it('commandType: local-jsx 推断为 interactive only', () => {
const cmd: SlashCommand = { name: 'test', description: '', kind: CommandKind.BUILT_IN, commandType: 'local-jsx' };
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
});
it('commandType: prompt 推断为 all modes', () => {
const cmd: SlashCommand = { name: 'test', description: '', kind: CommandKind.SKILL, commandType: 'prompt' };
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive', 'non_interactive', 'acp']);
});
it('未声明 commandType 且 CommandKind.BUILT_IN兜底为 interactive', () => {
const cmd: SlashCommand = { name: 'test', description: '', kind: CommandKind.BUILT_IN };
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
});
it('未声明 commandType 且 CommandKind.FILE兜底为 all modes', () => {
const cmd: SlashCommand = { name: 'test', description: '', kind: CommandKind.FILE };
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive', 'non_interactive', 'acp']);
});
it('未声明 commandType 且 CommandKind.MCP_PROMPT兜底为 all modes修复原有限制', () => {
const cmd: SlashCommand = { name: 'test', description: '', kind: CommandKind.MCP_PROMPT };
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive', 'non_interactive', 'acp']);
});
});
describe('filterCommandsForMode', () => {
it('正确过滤 non_interactive 模式下的命令', () => { ... });
it('正确过滤 acp 模式下的命令', () => { ... });
it('不过滤 hidden 命令filterCommandsForMode 不处理 hiddenCommandService 处理)', () => { ... });
});
```
### 11.2 更新 `nonInteractiveCliCommands.test.ts`
- 删除对 `ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE` 的所有引用
- 删除对 `allowedBuiltinCommandNames` 参数的测试用例
- 新增:验证 commandType: local 的命令在 non-interactive 下通过过滤
- 新增:验证 commandType: local-jsx 的命令在 non-interactive 下被过滤
- 保留:验证 file command / skill command 在 non-interactive 下通过过滤
### 11.3 更新 `CommandService.test.ts`
- 新增 `getCommandsForMode` 的测试用例
- 新增 `getModelInvocableCommands` 的测试用例
### 11.4 各 Loader 测试
- `BuiltinCommandLoader.test.ts`:验证所有命令都有 `source: 'builtin-command'`
- `BundledSkillLoader.test.ts`:验证 `source: 'bundled-skill'``modelInvocable: true`
- `FileCommandLoader.test.ts`:验证用户命令有 `source: 'skill-dir-command'`,插件命令有 `source: 'plugin-command'`
- `McpPromptLoader.test.ts`:验证 `source: 'mcp-prompt'``modelInvocable: true`
---
## 12. 实施顺序
建议按以下顺序实施,每步可独立 commit 和 review
**Step 1**~30min修改 `types.ts`,新增 `ExecutionMode``CommandSource``CommandType``SlashCommand` 新字段
→ 纯类型变更TypeScript 编译检查
**Step 2**~1h新建 `commandUtils.ts`,实现 `getEffectiveSupportedModes``filterCommandsForMode`,同步新建 `commandUtils.test.ts`
→ 单元测试覆盖核心逻辑
**Step 3**~1h重构 `nonInteractiveCliCommands.ts`,删除白名单,引入 `filterCommandsForMode`,更新函数签名
→ 行为等价Phase 1 保守策略local 类命令显式写 `supportedModes: ['interactive']`
**Step 4**~30min更新 `CommandService.ts`,新增两个方法
**Step 5**~2h为所有 built-in 命令文件添加 `commandType` 声明
→ 逐个确认分类正确性
**Step 6**~1.5h):更新所有 Loader注入 `source``sourceLabel``commandType``modelInvocable`
**Step 7**~30min更新 `Session.ts` 的调用签名
**Step 8**~1h运行所有测试修复失败用例更新快照
**Step 9**~30minCR 自查:确认白名单已完全移除,无遗漏调用
---
## 13. 验收 Checklist
- [ ] TypeScript 编译无错误(`npm run typecheck`
- [ ] `npm run lint` 无新增 lint 错误
- [ ] 所有现有测试通过(`cd packages/cli && npx vitest run`
- [ ] `commandUtils.test.ts` 新增测试全部通过
- [ ] `getEffectiveSupportedModes` 覆盖所有 7 种 case
- [ ] `filterCommandsForMode` 覆盖 interactive / non_interactive / acp 三种模式
- [ ] `ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE` 在整个代码库中无任何引用(`grep` 验证)
- [ ] `filterCommandsForNonInteractive` 函数在整个代码库中无任何引用
- [ ] 所有 built-in 命令有 `commandType` 字段
- [ ] 所有 Loader 输出的命令有 `source``sourceLabel` 字段
- [ ] `BundledSkillLoader` / `FileCommandLoader`(用户命令)/ `McpPromptLoader` 输出的命令 `modelInvocable: true`
- [ ] `BuiltinCommandLoader` 输出的命令 `modelInvocable: false`
- [ ] `CommandService.getCommandsForMode('non_interactive')` 返回与重构前等价的命令集
- [ ] MCP prompt 命令在 non-interactive 模式下不再被错误拦截

View file

@ -0,0 +1,263 @@
# Slash Command 重构路线图
## 总体目标
用 Qwen 内部架构风格,交付一个在外部体验上 95% 对齐 Claude Code 的 command 平台同时修复三模式分裂、命令来源单一、prompt command 无法被模型调用三个核心问题。
---
## 核心设计原则
1. **每个 Phase 可独立 ship**:完成后行为是自洽的,不依赖未来 Phase 才能运行
2. **Phase 1 是纯基础设施**:除修复 MCP_PROMPT 被错误拦截外,不改变任何现有可用命令集
3. **行为变化与架构变化分开**Phase 1 做架构Phase 2 做能力扩展
4. **不照搬 Claude Code 内部架构**:但对齐用户可感知的能力面
---
## Phase 1基础设施重建纯架构零行为变化
### 目标
建立统一的命令元数据模型和跨模式管理机制,为后续所有 Phase 提供底层支撑。
### 功能点
#### 1.1 扩展 `SlashCommand` 元数据模型
在现有 `SlashCommand` 接口上新增以下字段:
**来源字段**
- `source: CommandSource`:命令来源枚举(`builtin-command` / `bundled-skill` / `skill-dir-command` / `plugin-command` / `mcp-prompt` 等)
- `sourceLabel?: string`:展示用的来源标签(如 `"Built-in"` / `"MCP: github-server"`
**模式能力字段**
- `supportedModes: ExecutionMode[]`:声明在哪些运行模式下可用(`interactive` / `non_interactive` / `acp`
**执行类型字段**
- `commandType: CommandType`:声明执行类型(`prompt` / `local` / `local-jsx`
**可见性字段**
- `userInvocable: boolean`:用户是否可通过 slash command 调用(默认 `true`
- `modelInvocable: boolean`:模型是否可通过 tool call 调用(默认 `false`
**辅助元数据字段**(为 Phase 3 预留Phase 1 仅定义,不使用)
- `argumentHint?: string`:参数提示,如 `"<model-id>"` / `"show|list|set"`
- `whenToUse?: string`:何时调用该命令的说明(供模型使用)
- `examples?: string[]`:使用示例
#### 1.2 Loader 填充 source/commandType 字段
每个 Loader 在构建 `SlashCommand` 时必须填充 `source``commandType`
| Loader | source | commandType |
| -------------------------------- | ------------------- | ------------------------------------- |
| `BuiltinCommandLoader` | `builtin-command` | 由各命令声明(`local` / `local-jsx` |
| `BundledSkillLoader` | `bundled-skill` | `prompt` |
| `FileCommandLoader`(用户/项目) | `skill-dir-command` | `prompt` |
| `FileCommandLoader`(插件) | `plugin-command` | `prompt` |
| `McpPromptLoader` | `mcp-prompt` | `prompt` |
#### 1.3 内置命令声明 `supportedModes``commandType`
为所有 built-in 命令显式声明:
- `commandType``local`(无 UI 依赖)或 `local-jsx`(依赖 dialog/React
- `supportedModes``local` 类命令声明 `['interactive', 'non_interactive', 'acp']``local-jsx` 类命令声明 `['interactive']`
#### 1.4 用 capability-based 过滤替换硬编码白名单
- 删除 `ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE` 常量
- 删除 `filterCommandsForNonInteractive` 函数
- 新增 `filterCommandsForMode(commands, mode)` 函数,基于 `supportedModes` 字段过滤
- 新增 `getEffectiveSupportedModes(cmd)` 工具函数(考虑 CommandKind 默认策略)
- 修改 `handleSlashCommand` / `getAvailableCommands` 函数签名,移除 `allowedBuiltinCommandNames` 参数
#### 1.5 CommandService 升级为统一 Registry
- 新增 `getCommandsForMode(mode: ExecutionMode)` 方法
- 新增 `getModelInvocableCommands()` 方法Phase 2/3 使用Phase 1 提供接口)
- 现有 `getCommands()` 保持不变interactive 使用)
### 验收标准
- [ ] `SlashCommand` 接口包含所有新字段TypeScript 编译通过
- [ ] 所有 Loader 填充 `source``commandType` 字段
- [ ] 所有 built-in 命令声明 `commandType``supportedModes`
- [ ] `ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE` 已删除,被 capability filter 取代
- [ ] **non-interactive 下可用命令集与重构前完全一致**(现有测试不 break
- [ ] MCP prompt commands 在 non-interactive/acp 下可正常执行(修复原有错误限制)
- [ ] `CommandService.getCommandsForMode('non_interactive')` 返回正确的命令集
- [ ] 所有现有测试通过
---
## Phase 2能力扩展命令整理与 prompt command 模型调用)
### 目标
基于 Phase 1 的元数据基础,扩展三种模式下的命令可用范围,并打通 prompt command 的模型调用通路。
### 功能点
#### 2.1 扩展 non-interactive / acp 可用命令集
将以下命令的 `supportedModes` 扩展到包含 `non_interactive``acp`,并确保其 action 实现可在无 UI 环境运行:
**直接可扩展**action 已无 UI 依赖):
- `/export`:文件 I/O返回 `message`
- `/memory`:文件 I/O返回 `message`
- `/plan`:返回 `submit_prompt`
- `/tools`:改为返回 `message`(文本列表,替换 UI 渲染)
- `/stats`:改为返回 `message`(文本格式,替换 UI 渲染)
**需要 local 子命令拆分**(当前只有 `local-jsx` 壳):
| 命令 | 新增的 local 子命令 |
| -------------- | ----------------------------------------------------------------------------- |
| `/model` | `show`(当前模型)、`list`(可选列表)、`set <id>`(切换) |
| `/permissions` | `show`(当前权限模式)、`set <mode>`(设置) |
| `/mcp` | `list`MCP 服务列表)、`show <server>`(服务详情)、`status`(所有服务状态) |
| `/memory` | 已有 `show`/`add`/`refresh`(确认 non-interactive 下可用) |
> **注意**:上述 UI 壳命令不会被删除,`/model` 不带子命令时仍然打开 dialoginteractive 模式)。新增子命令是 **在现有命令上追加**,不是替换。
#### 2.2 prompt command 模型调用打通
- 在 `CommandService`(或 `CommandRegistry`)中实现 `getModelInvocableCommands()`,返回所有 `modelInvocable: true` 的命令
- 将 `BundledSkillLoader``FileCommandLoader`(用户/项目命令)、`McpPromptLoader` 加载的命令标记为 `modelInvocable: true`
- 改造 `SkillTool`:从只消费 `SkillManager.listSkills()` 改为同时消费 `CommandService.getModelInvocableCommands()`
- 构建统一的模型可调用命令描述,注入 `SkillTool` 的 description
#### 2.3 mid-input slash command 检测(基础版)
- 在 `InputPrompt` 中检测光标附近的 slash token不限于行首
- 检测到 slash token 后触发补全菜单(展示命令名 + description
- 补全菜单弹出位置跟随光标
- **不**包含 argument hints、source badge 等Phase 3 做)
### 验收标准
- [ ] `/export``/memory``/plan``/tools``/stats` 在 non-interactive 模式下可正常执行并返回结构化输出
- [ ] `/model show``/model set <id>` 在 non-interactive / acp 下可执行
- [ ] `/permissions show``/permissions set <mode>` 在 non-interactive / acp 下可执行
- [ ] `/mcp list``/mcp show <server>` 在 non-interactive / acp 下可执行
- [ ] 模型在对话中可以通过 `SkillTool` 调用 bundled skill、file command用户/项目、MCP prompt
- [ ] 模型不可以调用 built-in commands`userInvocable: true``modelInvocable: false`
- [ ] mid-input slash在正文中输入 `/` 后触发命令补全菜单
- [ ] `SkillTool` 的 description 包含所有 `modelInvocable` 命令的描述
---
## Phase 3体验对齐补全增强 + Claude Code 命令补齐)
### 目标
在 Phase 1/2 的元数据和命令能力基础上,补齐补全体验,并补充 Claude Code 中存在而 Qwen Code 缺失的命令。
### 功能点
#### 3.1 补全体验增强
**source badge**
- 在补全菜单中展示命令来源标签(`[MCP]` 已有,扩展为 `[Skill]``[Custom]` 等)
- 使用 `source` / `sourceLabel` 字段渲染
**argument hint**
- 补全菜单中命令名后展示 `argumentHint`(如 `set <model-id>`
- `argumentHint` 由 Phase 1 元数据字段提供
**recently used 排序**
- 记录用户最近使用的命令session 级别,无需持久化)
- 在补全排序中给近期使用的命令加权
**alias 命中高亮**
- 当补全命中 `altNames` 而非主名时,在展示时注明(如 `help (alias: ?)`
**冲突策略对齐**
- 明确优先级built-in > bundled/skill-dir > plugin > mcp
- 冲突时将低优先级命令重命名(如 `pluginName.commandName`
#### 3.2 mid-input slash command 完整版
- 在 Phase 2 基础版上增加 argument hints 和 source badge 展示
- ghost text 提示(输入 `/he` 时显示 `/help` 的淡色提示)
- 有效命令 token 高亮(已完成匹配的 slash command 显示不同颜色)
#### 3.3 Help 目录重构
`/help` 从平铺列表改为分组目录:
- **Built-in Commands**local + local-jsx注明 mode
- **Bundled Skills**
- **Custom Commands**(用户/项目 file commands
- **Plugin Commands**
- **MCP Commands**
每条命令展示名称、argumentHint、description、source、supportedModes 标记
#### 3.4 ACP available commands 元数据增强
`sendAvailableCommandsUpdate()` 中将更多元数据暴露给 ACP 客户端:
- `argumentHint`
- `source`
- `supportedModes`
- `subcommands`(名称列表)
- `modelInvocable`
#### 3.5 Claude Code 缺失命令补齐
补充 Qwen Code 当前没有、Claude Code 有且常用的命令:
| 命令 | 类型 | 说明 |
| ---------------- | ------- | ---------------------------------------- |
| `/doctor` | `local` | 环境自检,输出配置/连接/工具状态诊断 |
| `/release-notes` | `local` | 展示当前版本的更新日志 |
| `/cost` | `local` | 展示当前 session 的 token 消耗和费用估算 |
> 注:`/review``/commit` 等任务类命令以 bundled skill 形式提供,不在此列。
### 验收标准
- [ ] 补全菜单展示 source badge`[MCP]``[Skill]``[Custom]`
- [ ] 补全菜单展示 argumentHint`set <model-id>`
- [ ] 近期使用的命令在补全列表中优先出现
- [ ] alias 命中时在补全项中注明原名
- [ ] mid-input slashghost text 提示正确渲染
- [ ] `/help` 输出按来源分组,每条命令展示支持模式标记
- [ ] ACP available commands 包含 `argumentHint``source``subcommands` 字段
- [ ] `/doctor``/release-notes``/cost` 三个命令可用
- [ ] `/doctor` 在 non-interactive 模式下可执行(返回 `message`
---
## 各 Phase 依赖关系
```
Phase 1元数据 + 统一过滤)
├──► Phase 2能力扩展
│ │
│ ├──► slash command 子命令拆分
│ └──► prompt command 模型调用(需要 getModelInvocableCommands()
└──► Phase 3体验对齐
├──► source badge需要 Phase 1 source 字段)
├──► argument hint需要 Phase 1 argumentHint 字段)
└──► Help 分组(需要 Phase 1 source 字段)
```
Phase 2 和 Phase 3 不互相依赖,可以并行推进(或根据优先级调换部分子项)。

View file

@ -210,10 +210,7 @@ describe('Session', () => {
expect(getAvailableCommandsSpy).toHaveBeenCalledWith(
mockConfig,
expect.any(AbortSignal),
[
...nonInteractiveCliCommands.ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
'insight',
],
'acp',
);
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
sessionId: 'test-session-id',

View file

@ -73,7 +73,6 @@ import type { LoadedSettings } from '../../config/settings.js';
import { z } from 'zod';
import { normalizePartList } from '../../utils/nonInteractiveHelpers.js';
import {
ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
handleSlashCommand,
getAvailableCommands,
type NonInteractiveSlashCommandResult,
@ -82,11 +81,6 @@ import { isSlashCommand } from '../../ui/utils/commandUtils.js';
import { parseAcpModelOption } from '../../utils/acpModelUtils.js';
import { classifyApiError } from '../../ui/hooks/useGeminiStream.js';
const ACP_ALLOWED_COMMANDS = [
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
'insight',
];
// Import modular session components
import type {
ApprovalModeValue,
@ -330,13 +324,12 @@ export class Session implements SessionContext {
let parts: Part[] | null;
if (isSlashCommand(inputText)) {
// ACP supports the standard non-interactive built-ins plus /insight.
// Handle slash command in ACP mode using capability-based filtering
const slashCommandResult = await handleSlashCommand(
inputText,
pendingSend,
this.config,
this.settings,
ACP_ALLOWED_COMMANDS,
);
parts = await this.#processSlashCommandResult(
@ -968,11 +961,11 @@ export class Session implements SessionContext {
async sendAvailableCommandsUpdate(): Promise<void> {
const abortController = new AbortController();
try {
// Use default allowed commands from getAvailableCommands
// Load commands available in ACP mode
const slashCommands = await getAvailableCommands(
this.config,
abortController.signal,
ACP_ALLOWED_COMMANDS,
'acp',
);
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol

View file

@ -444,7 +444,11 @@ export class SystemController extends BaseController {
}
try {
const commands = await getAvailableCommands(this.context.config, signal);
const commands = await getAvailableCommands(
this.context.config,
signal,
'non_interactive',
);
if (signal.aborted) {
return [];

View file

@ -26,7 +26,8 @@ import type { Part } from '@google/genai';
import { runNonInteractive } from './nonInteractiveCli.js';
import { vi, type Mock, type MockInstance } from 'vitest';
import type { LoadedSettings } from './config/settings.js';
import { CommandKind } from './ui/commands/types.js';
import { CommandKind, type ExecutionMode } from './ui/commands/types.js';
import { filterCommandsForMode } from './services/commandUtils.js';
// Mock core modules
vi.mock('./ui/hooks/atCommandProcessor.js');
@ -54,6 +55,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
});
const mockGetCommands = vi.hoisted(() => vi.fn());
const mockGetCommandsForMode = vi.hoisted(() => vi.fn());
const mockCommandServiceCreate = vi.hoisted(() => vi.fn());
vi.mock('./services/CommandService.js', () => ({
CommandService: {
@ -79,8 +81,12 @@ describe('runNonInteractive', () => {
beforeEach(async () => {
mockCoreExecuteToolCall = vi.mocked(executeToolCall);
mockShutdownTelemetry = vi.mocked(shutdownTelemetry);
mockGetCommandsForMode.mockImplementation((mode: ExecutionMode) =>
filterCommandsForMode(mockGetCommands(), mode),
);
mockCommandServiceCreate.mockResolvedValue({
getCommands: mockGetCommands,
getCommandsForMode: mockGetCommandsForMode,
});
processStdoutSpy = vi
@ -976,7 +982,7 @@ describe('runNonInteractive', () => {
// Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter)
expect(processStderrSpy).toHaveBeenCalledWith(
'The command "/help" is not supported in non-interactive mode.\n',
'The command "/help" is not supported in this mode.\n',
);
});

View file

@ -8,10 +8,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
import type { Config } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from './config/settings.js';
import { CommandKind } from './ui/commands/types.js';
import { CommandKind, type ExecutionMode } from './ui/commands/types.js';
import { filterCommandsForMode } from './services/commandUtils.js';
// Mock the CommandService
const mockGetCommands = vi.hoisted(() => vi.fn());
const mockGetCommandsForMode = vi.hoisted(() => vi.fn());
const mockCommandServiceCreate = vi.hoisted(() => vi.fn());
vi.mock('./services/CommandService.js', () => ({
CommandService: {
@ -25,8 +27,13 @@ describe('handleSlashCommand', () => {
let abortController: AbortController;
beforeEach(() => {
// getCommandsForMode applies real mode filtering on top of getCommands()
mockGetCommandsForMode.mockImplementation((mode: ExecutionMode) =>
filterCommandsForMode(mockGetCommands(), mode),
);
mockCommandServiceCreate.mockResolvedValue({
getCommands: mockGetCommands,
getCommandsForMode: mockGetCommandsForMode,
});
mockConfig = {
@ -74,11 +81,12 @@ describe('handleSlashCommand', () => {
expect(result.type).toBe('no_command');
});
it('should return unsupported for known built-in commands not in allowed list', async () => {
it('should return unsupported for built-in commands without non-interactive supportedModes', async () => {
const mockHelpCommand = {
name: 'help',
description: 'Show help',
kind: CommandKind.BUILT_IN,
// No commandType → falls back to BUILT_IN → interactive only
action: vi.fn(),
};
mockGetCommands.mockReturnValue([mockHelpCommand]);
@ -88,7 +96,6 @@ describe('handleSlashCommand', () => {
abortController,
mockConfig,
mockSettings,
[], // Empty allowed list
);
expect(result.type).toBe('unsupported');
@ -118,78 +125,18 @@ describe('handleSlashCommand', () => {
expect(result.type).toBe('unsupported');
if (result.type === 'unsupported') {
expect(result.reason).toBe(
'The command "/help" is not supported in non-interactive mode.',
'The command "/help" is not supported in this mode.',
);
}
});
it('should return unsupported (not no_command) for a disabled command so it is not forwarded to the model', async () => {
const mockInitCommand = {
name: 'init',
description: 'Initialize project',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
};
mockGetCommands.mockReturnValue([mockInitCommand]);
vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['init']);
const result = await handleSlashCommand(
'/init',
abortController,
mockConfig,
mockSettings,
['init'], // Would normally be allowed; denylist must still block it.
);
expect(result.type).toBe('unsupported');
if (result.type === 'unsupported') {
expect(result.reason).toContain('/init');
expect(result.reason).toContain('disabled');
}
expect(mockInitCommand.action).not.toHaveBeenCalled();
});
it('should match disabled names case-insensitively', async () => {
const mockInitCommand = {
name: 'init',
description: 'Initialize project',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
};
mockGetCommands.mockReturnValue([mockInitCommand]);
vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['INIT']);
const result = await handleSlashCommand(
'/init',
abortController,
mockConfig,
mockSettings,
['init'],
);
expect(result.type).toBe('unsupported');
expect(mockInitCommand.action).not.toHaveBeenCalled();
});
it('should still return no_command for truly unknown slash commands even when a denylist is set', async () => {
mockGetCommands.mockReturnValue([]);
vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['help']);
const result = await handleSlashCommand(
'/does-not-exist',
abortController,
mockConfig,
mockSettings,
);
expect(result.type).toBe('no_command');
});
it('should execute allowed built-in commands', async () => {
it('should execute local commands with non_interactive supportedModes', async () => {
const mockInitCommand = {
name: 'init',
description: 'Initialize project',
kind: CommandKind.BUILT_IN,
commandType: 'local' as const,
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: vi.fn().mockResolvedValue({
type: 'message',
messageType: 'info',
@ -203,7 +150,6 @@ describe('handleSlashCommand', () => {
abortController,
mockConfig,
mockSettings,
['init'], // init is in the allowed list
);
expect(result.type).toBe('message');
@ -212,11 +158,13 @@ describe('handleSlashCommand', () => {
}
});
it('should execute /btw when using the default allowed list', async () => {
it('should execute /btw with non_interactive supportedModes', async () => {
const mockBtwCommand = {
name: 'btw',
description: 'Ask a side question',
kind: CommandKind.BUILT_IN,
commandType: 'local' as const,
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: vi.fn().mockResolvedValue({
type: 'message',
messageType: 'info',
@ -239,7 +187,7 @@ describe('handleSlashCommand', () => {
}
});
it('should execute file commands regardless of allowed list', async () => {
it('should execute FILE commands in any mode without explicit supportedModes', async () => {
const mockFileCommand = {
name: 'custom',
description: 'Custom file command',
@ -256,7 +204,6 @@ describe('handleSlashCommand', () => {
abortController,
mockConfig,
mockSettings,
[], // Empty allowed list, but FILE commands should still work
);
expect(result.type).toBe('submit_prompt');

View file

@ -17,10 +17,10 @@ import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
import { BundledSkillLoader } from './services/BundledSkillLoader.js';
import { FileCommandLoader } from './services/FileCommandLoader.js';
import {
CommandKind,
type CommandContext,
type SlashCommand,
type SlashCommandActionReturn,
type ExecutionMode,
} from './ui/commands/types.js';
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
import type { LoadedSettings } from './config/settings.js';
@ -29,27 +29,6 @@ import { t } from './i18n/index.js';
const debugLogger = createDebugLogger('NON_INTERACTIVE_COMMANDS');
/**
* Built-in commands that are allowed in non-interactive modes (CLI and ACP).
* Only safe, read-only commands that don't require interactive UI.
*
* These commands are:
* - init: Initialize project configuration
* - summary: Generate session summary
* - compress: Compress conversation history
* - context: Show context window usage (read-only diagnostic)
* - doctor: Run installation and environment diagnostics (read-only diagnostic)
*/
export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [
'init',
'summary',
'compress',
'btw',
'bug',
'context',
'doctor',
] as const;
/**
* Result of handling a slash command in non-interactive mode.
*
@ -187,36 +166,6 @@ function handleCommandResult(
}
}
/**
* Filters commands based on the allowed built-in command names.
*
* - Always includes FILE commands
* - Only includes BUILT_IN commands if their name is in the allowed set
* - Excludes other command types (e.g., MCP_PROMPT) in non-interactive mode
*
* @param commands All loaded commands
* @param allowedBuiltinCommandNames Set of allowed built-in command names (empty = none allowed)
* @returns Filtered commands
*/
function filterCommandsForNonInteractive(
commands: readonly SlashCommand[],
allowedBuiltinCommandNames: Set<string>,
): SlashCommand[] {
return commands.filter((cmd) => {
if (cmd.kind === CommandKind.FILE || cmd.kind === CommandKind.SKILL) {
return true;
}
// Built-in commands: only include if in the allowed list
if (cmd.kind === CommandKind.BUILT_IN) {
return allowedBuiltinCommandNames.has(cmd.name);
}
// Exclude other types (e.g., MCP_PROMPT) in non-interactive mode
return false;
});
}
/**
* Processes a slash command in a non-interactive environment.
*
@ -224,9 +173,6 @@ function filterCommandsForNonInteractive(
* @param abortController Controller to cancel the operation
* @param config The configuration object
* @param settings The loaded settings
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
* allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress).
* Pass an empty array to only allow file commands.
* @returns A Promise that resolves to a `NonInteractiveSlashCommandResult` describing
* the outcome of the command execution.
*/
@ -235,9 +181,6 @@ export const handleSlashCommand = async (
abortController: AbortController,
config: Config,
settings: LoadedSettings,
allowedBuiltinCommandNames: string[] = [
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
],
): Promise<NonInteractiveSlashCommandResult> => {
const trimmed = rawQuery.trim();
if (!trimmed.startsWith('/')) {
@ -247,26 +190,13 @@ export const handleSlashCommand = async (
const isAcpMode = config.getExperimentalZedIntegration();
const isInteractive = config.isInteractive();
const executionMode = isAcpMode
const executionMode: ExecutionMode = isAcpMode
? 'acp'
: isInteractive
? 'interactive'
: 'non_interactive';
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
const disabledSlashCommandsRaw = config.getDisabledSlashCommands();
const disabledNameSet = new Set<string>();
for (const name of disabledSlashCommandsRaw) {
const trimmed = name.trim();
if (trimmed) disabledNameSet.add(trimmed.toLowerCase());
}
const isDisabled = (cmd: { name: string }) =>
disabledNameSet.has(cmd.name.toLowerCase());
// Load the full command set (unfiltered by the denylist) so that the
// fallback existence check below can distinguish a disabled command from a
// truly unknown one. Without this, a disabled command would fall through to
// `no_command` and be forwarded to the model as plain prompt text.
// Load all commands to check if the command exists but is not allowed
const allLoaders = [
new BuiltinCommandLoader(config),
new BundledSkillLoader(config),
@ -278,10 +208,7 @@ export const handleSlashCommand = async (
abortController.signal,
);
const allCommands = commandService.getCommands();
const filteredCommands = filterCommandsForNonInteractive(
allCommands,
allowedBuiltinSet,
).filter((cmd) => !isDisabled(cmd));
const filteredCommands = commandService.getCommandsForMode(executionMode);
// First, try to parse with filtered commands
const { commandToExecute, args } = parseSlashCommand(
@ -297,23 +224,12 @@ export const handleSlashCommand = async (
);
if (knownCommand) {
if (isDisabled(knownCommand)) {
return {
type: 'unsupported',
reason: t(
'The command "/{{command}}" is disabled by the current configuration.',
{ command: knownCommand.name },
),
originalType: 'filtered_command',
};
}
// Command exists but is not allowed in non-interactive mode
// Command exists but is not allowed in this mode
return {
type: 'unsupported',
reason: t(
'The command "/{{command}}" is not supported in non-interactive mode.',
{ command: knownCommand.name },
),
reason: t('The command "/{{command}}" is not supported in this mode.', {
command: knownCommand.name,
}),
originalType: 'filtered_command',
};
}
@ -372,51 +288,27 @@ export const handleSlashCommand = async (
};
/**
* Retrieves all available slash commands for the current configuration.
* Retrieves all available slash commands for the given execution mode.
*
* @param config The configuration object
* @param abortSignal Signal to cancel the loading process
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
* allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress).
* Pass an empty array to only include file commands.
* @param mode The execution mode to filter commands for. Defaults to 'acp'.
* @returns A Promise that resolves to an array of SlashCommand objects
*/
export const getAvailableCommands = async (
config: Config,
abortSignal: AbortSignal,
allowedBuiltinCommandNames: string[] = [
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
],
mode: ExecutionMode = 'acp',
): Promise<SlashCommand[]> => {
try {
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
const loaders = [
new BuiltinCommandLoader(config),
new BundledSkillLoader(config),
new FileCommandLoader(config),
];
// Only load BuiltinCommandLoader if there are allowed built-in commands
const loaders =
allowedBuiltinSet.size > 0
? [
new BuiltinCommandLoader(config),
new BundledSkillLoader(config),
new FileCommandLoader(config),
]
: [new BundledSkillLoader(config), new FileCommandLoader(config)];
const disabledSlashCommands = config.getDisabledSlashCommands();
const commandService = await CommandService.create(
loaders,
abortSignal,
disabledSlashCommands.length > 0
? new Set(disabledSlashCommands)
: undefined,
);
const commands = commandService.getCommands();
const filteredCommands = filterCommandsForNonInteractive(
commands,
allowedBuiltinSet,
);
// Filter out hidden commands
return filteredCommands.filter((cmd) => !cmd.hidden);
const commandService = await CommandService.create(loaders, abortSignal);
return commandService.getCommandsForMode(mode) as SlashCommand[];
} catch (error) {
// Handle errors gracefully - log and return empty array
debugLogger.error('Error loading available commands:', error);

View file

@ -135,6 +135,14 @@ export class BuiltinCommandLoader implements ICommandLoader {
statuslineCommand,
];
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
return allDefinitions
.filter((cmd): cmd is SlashCommand => cmd !== null)
.map((cmd) => ({
...cmd,
source: 'builtin-command' as const,
sourceLabel: 'Built-in',
modelInvocable: false,
userInvocable: cmd.userInvocable ?? true,
}));
}
}

View file

@ -63,6 +63,10 @@ export class BundledSkillLoader implements ICommandLoader {
name: skill.name,
description: skill.description,
kind: CommandKind.SKILL,
source: 'bundled-skill' as const,
sourceLabel: 'Skill',
commandType: 'prompt' as const,
modelInvocable: true,
action: async (context, _args): Promise<SlashCommandActionReturn> => {
// Resolve template variables in skill body
let body = skill.body;

View file

@ -4,9 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand } from '../ui/commands/types.js';
import type { SlashCommand, ExecutionMode } from '../ui/commands/types.js';
import type { ICommandLoader } from './types.js';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import { filterCommandsForMode } from './commandUtils.js';
const debugLogger = createDebugLogger('CLI_COMMANDS');
@ -124,4 +125,27 @@ export class CommandService {
getCommands(): readonly SlashCommand[] {
return this.commands;
}
/**
* Returns commands available in the specified execution mode.
* Hidden commands are excluded.
*/
getCommandsForMode(mode: ExecutionMode): readonly SlashCommand[] {
return Object.freeze(
filterCommandsForMode(
this.commands.filter((cmd) => !cmd.hidden),
mode,
),
);
}
/**
* Returns commands that the model is allowed to invoke (modelInvocable === true).
* Hidden commands are excluded.
*/
getModelInvocableCommands(): readonly SlashCommand[] {
return this.commands.filter(
(cmd) => !cmd.hidden && cmd.modelInvocable === true,
);
}
}

View file

@ -46,6 +46,10 @@ export class McpPromptLoader implements ICommandLoader {
name: commandName,
description: prompt.description || `Invoke prompt ${prompt.name}`,
kind: CommandKind.MCP_PROMPT,
source: 'mcp-prompt' as const,
sourceLabel: `MCP: ${serverName}`,
commandType: 'prompt' as const,
modelInvocable: true,
subCommands: [
{
name: 'help',

View file

@ -13,6 +13,7 @@ import path from 'node:path';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import type {
CommandContext,
CommandSource,
SlashCommand,
SlashCommandActionReturn,
} from '../ui/commands/types.js';
@ -111,6 +112,12 @@ export function createSlashCommandFromDefinition(
description,
kind: CommandKind.FILE,
extensionName,
source: (extensionName
? 'plugin-command'
: 'skill-dir-command') as CommandSource,
sourceLabel: extensionName ? `Plugin: ${extensionName}` : 'Custom',
commandType: 'prompt' as const,
modelInvocable: !extensionName,
action: async (
context: CommandContext,
_args: string,

View file

@ -0,0 +1,212 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
getEffectiveSupportedModes,
filterCommandsForMode,
} from './commandUtils.js';
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
/** Minimal SlashCommand factory for tests */
function makeCmd(overrides: Partial<SlashCommand>): SlashCommand {
return {
name: 'test',
description: 'test command',
kind: CommandKind.BUILT_IN,
...overrides,
};
}
describe('getEffectiveSupportedModes', () => {
// ── Priority 1: explicit supportedModes ───────────────────────────────
it('explicit supportedModes overrides commandType inference', () => {
const cmd = makeCmd({
commandType: 'local',
supportedModes: ['interactive'],
});
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
});
it('explicit supportedModes can expand to all modes even for local-jsx', () => {
const cmd = makeCmd({
commandType: 'local-jsx',
supportedModes: ['interactive', 'non_interactive', 'acp'],
});
expect(getEffectiveSupportedModes(cmd)).toEqual([
'interactive',
'non_interactive',
'acp',
]);
});
it('explicit empty supportedModes returns empty array', () => {
const cmd = makeCmd({ supportedModes: [] });
expect(getEffectiveSupportedModes(cmd)).toEqual([]);
});
// ── Priority 2: commandType inference ─────────────────────────────────
it('commandType: prompt infers all modes', () => {
const cmd = makeCmd({ kind: CommandKind.SKILL, commandType: 'prompt' });
expect(getEffectiveSupportedModes(cmd)).toEqual([
'interactive',
'non_interactive',
'acp',
]);
});
it('commandType: local infers interactive only (conservative default)', () => {
const cmd = makeCmd({ commandType: 'local' });
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
});
it('commandType: local-jsx infers interactive only', () => {
const cmd = makeCmd({ commandType: 'local-jsx' });
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
});
it('commandType: local with explicit supportedModes can unlock non_interactive', () => {
const cmd = makeCmd({
commandType: 'local',
supportedModes: ['interactive', 'non_interactive', 'acp'],
});
expect(getEffectiveSupportedModes(cmd)).toEqual([
'interactive',
'non_interactive',
'acp',
]);
});
// ── Priority 3: CommandKind fallback (backward compat) ────────────────
it('no commandType, CommandKind.BUILT_IN falls back to interactive only', () => {
const cmd = makeCmd({ kind: CommandKind.BUILT_IN });
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
});
it('no commandType, CommandKind.FILE falls back to all modes', () => {
const cmd = makeCmd({ kind: CommandKind.FILE });
expect(getEffectiveSupportedModes(cmd)).toEqual([
'interactive',
'non_interactive',
'acp',
]);
});
it('no commandType, CommandKind.SKILL falls back to all modes', () => {
const cmd = makeCmd({ kind: CommandKind.SKILL });
expect(getEffectiveSupportedModes(cmd)).toEqual([
'interactive',
'non_interactive',
'acp',
]);
});
it('no commandType, CommandKind.MCP_PROMPT falls back to all modes (fixes original bug)', () => {
const cmd = makeCmd({ kind: CommandKind.MCP_PROMPT });
expect(getEffectiveSupportedModes(cmd)).toEqual([
'interactive',
'non_interactive',
'acp',
]);
});
});
describe('filterCommandsForMode', () => {
const commands: SlashCommand[] = [
makeCmd({
name: 'init',
commandType: 'local',
supportedModes: ['interactive', 'non_interactive', 'acp'],
}),
makeCmd({
name: 'model',
commandType: 'local-jsx',
// no explicit supportedModes → interactive only
}),
makeCmd({
name: 'review',
kind: CommandKind.SKILL,
commandType: 'prompt',
}),
makeCmd({
name: 'gh-prompt',
kind: CommandKind.MCP_PROMPT,
commandType: 'prompt',
}),
makeCmd({
name: 'my-script',
kind: CommandKind.FILE,
commandType: 'prompt',
}),
];
it('interactive mode includes all commands', () => {
const result = filterCommandsForMode(commands, 'interactive');
expect(result.map((c) => c.name)).toEqual([
'init',
'model',
'review',
'gh-prompt',
'my-script',
]);
});
it('non_interactive mode excludes local-jsx commands', () => {
const result = filterCommandsForMode(commands, 'non_interactive');
expect(result.map((c) => c.name)).toEqual([
'init',
'review',
'gh-prompt',
'my-script',
]);
});
it('acp mode excludes local-jsx commands', () => {
const result = filterCommandsForMode(commands, 'acp');
expect(result.map((c) => c.name)).toEqual([
'init',
'review',
'gh-prompt',
'my-script',
]);
});
it('non_interactive includes MCP_PROMPT commands (bug fix)', () => {
const result = filterCommandsForMode(commands, 'non_interactive');
expect(result.some((c) => c.name === 'gh-prompt')).toBe(true);
});
it('does not filter hidden commands (hidden filtering is caller responsibility)', () => {
const withHidden = [
...commands,
makeCmd({ name: 'hidden-cmd', commandType: 'local', hidden: true }),
];
const result = filterCommandsForMode(withHidden, 'non_interactive');
// filterCommandsForMode does NOT filter hidden — it only filters by mode
// hidden-cmd has commandType: 'local' but no supportedModes, so it's interactive only
expect(result.some((c) => c.name === 'hidden-cmd')).toBe(false);
});
it('hidden local command with explicit supportedModes still passes mode filter', () => {
const withHidden = [
...commands,
makeCmd({
name: 'hidden-cmd',
commandType: 'local',
hidden: true,
supportedModes: ['interactive', 'non_interactive', 'acp'],
}),
];
const result = filterCommandsForMode(withHidden, 'non_interactive');
// filterCommandsForMode passes it through — CommandService.getCommandsForMode removes hidden
expect(result.some((c) => c.name === 'hidden-cmd')).toBe(true);
});
it('returns empty array when no commands match', () => {
const jsxOnly = [makeCmd({ name: 'model', commandType: 'local-jsx' })];
expect(filterCommandsForMode(jsxOnly, 'non_interactive')).toEqual([]);
});
});

View file

@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Utility functions for slash command mode filtering.
*
* This module provides the core capability-based filtering logic that replaces
* the hardcoded ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE whitelist.
*/
import {
CommandKind,
type ExecutionMode,
type SlashCommand,
} from '../ui/commands/types.js';
/**
* Returns the effective list of execution modes for a command.
*
* Priority (highest to lowest):
* 1. Explicit `supportedModes` declaration on the command
* 2. Inference from `commandType`
* 3. Fallback based on `CommandKind` (backward-compat for commands that
* have not yet been migrated to declare commandType)
*
* @param cmd The slash command to evaluate.
* @returns The list of execution modes in which the command is available.
*/
export function getEffectiveSupportedModes(cmd: SlashCommand): ExecutionMode[] {
// Priority 1: explicit declaration wins
if (cmd.supportedModes !== undefined) {
return cmd.supportedModes;
}
// Priority 2: infer from commandType
if (cmd.commandType !== undefined) {
switch (cmd.commandType) {
case 'prompt':
// prompt commands have no UI dependency — available in all modes
return ['interactive', 'non_interactive', 'acp'];
case 'local':
// local commands default to interactive only (conservative).
// Commands that are verified headless-friendly must explicitly declare
// supportedModes (mirrors Claude Code's supportsNonInteractive: true).
return ['interactive'];
case 'local-jsx':
// local-jsx commands always require the React/Ink runtime
return ['interactive'];
default:
return ['interactive'];
}
}
// Priority 3: backward-compat fallback based on CommandKind.
// This branch should not be hit once all commands declare commandType.
switch (cmd.kind) {
case CommandKind.BUILT_IN:
// Conservative default for unmigrated built-in commands
return ['interactive'];
case CommandKind.FILE:
case CommandKind.SKILL:
case CommandKind.MCP_PROMPT:
// These kinds have always been available in all modes
return ['interactive', 'non_interactive', 'acp'];
default:
return ['interactive'];
}
}
/**
* Filters a list of commands to those available in the given execution mode.
*
* This function replaces `filterCommandsForNonInteractive`. It does NOT filter
* out hidden commands that responsibility belongs to the caller (e.g.,
* CommandService.getCommandsForMode).
*
* @param commands The full list of loaded commands.
* @param mode The target execution mode.
* @returns Commands that support the given mode.
*/
export function filterCommandsForMode(
commands: readonly SlashCommand[],
mode: ExecutionMode,
): SlashCommand[] {
return commands.filter((cmd) =>
getEffectiveSupportedModes(cmd).includes(mode),
);
}

View file

@ -17,6 +17,7 @@ export const aboutCommand: SlashCommand = {
return t('show version info');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (context) => {
const systemInfo = await getExtendedSystemInfo(context);

View file

@ -17,6 +17,7 @@ export const agentsCommand: SlashCommand = {
return t('Manage subagents for specialized task delegation.');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
subCommands: [
{
name: 'manage',
@ -24,6 +25,7 @@ export const agentsCommand: SlashCommand = {
return t('Manage existing subagents (view, edit, delete).');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'subagent_list',
@ -35,6 +37,7 @@ export const agentsCommand: SlashCommand = {
return t('Create a new subagent with guided setup.');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'subagent_create',

View file

@ -34,6 +34,7 @@ export const approvalModeCommand: SlashCommand = {
return t('View or change the approval mode for tool usage');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (
context: CommandContext,
args: string,

View file

@ -384,12 +384,14 @@ export const arenaCommand: SlashCommand = {
name: 'arena',
description: 'Manage Arena sessions',
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
subCommands: [
{
name: 'start',
description:
'Start an Arena session with multiple models competing on the same task',
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (
context: CommandContext,
args: string,
@ -446,6 +448,7 @@ export const arenaCommand: SlashCommand = {
name: 'stop',
description: 'Stop the current Arena session',
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
@ -487,6 +490,7 @@ export const arenaCommand: SlashCommand = {
name: 'status',
description: 'Show the current Arena session status',
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
@ -529,6 +533,7 @@ export const arenaCommand: SlashCommand = {
description:
'Select a model result and merge its diff into the current workspace',
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (
context: CommandContext,
args: string,

View file

@ -15,6 +15,7 @@ export const authCommand: SlashCommand = {
return t('Configure authentication information for login');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'auth',

View file

@ -123,6 +123,8 @@ export const btwCommand: SlashCommand = {
);
},
kind: CommandKind.BUILT_IN,
commandType: 'local',
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: async (
context: CommandContext,
args: string,

View file

@ -21,6 +21,8 @@ export const bugCommand: SlashCommand = {
return t('submit a bug report');
},
kind: CommandKind.BUILT_IN,
commandType: 'local',
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: async (context: CommandContext, args?: string): Promise<void> => {
const bugDescription = (args || '').trim();
const systemInfo = await getExtendedSystemInfo(context);

View file

@ -22,6 +22,7 @@ export const clearCommand: SlashCommand = {
return t('Clear conversation history and free up context');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (context, _args) => {
const { config } = context.services;

View file

@ -17,6 +17,8 @@ export const compressCommand: SlashCommand = {
return t('Compresses the context by replacing it with a summary.');
},
kind: CommandKind.BUILT_IN,
commandType: 'local',
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: async (context) => {
const { ui } = context;
const executionMode = context.executionMode ?? 'interactive';

View file

@ -316,6 +316,8 @@ export const contextCommand: SlashCommand = {
);
},
kind: CommandKind.BUILT_IN,
commandType: 'local',
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: async (context: CommandContext, args?: string) => {
const showDetails =
args?.trim().toLowerCase() === 'detail' ||
@ -360,6 +362,8 @@ export const contextCommand: SlashCommand = {
return t('Show per-item context usage breakdown.');
},
kind: CommandKind.BUILT_IN,
commandType: 'local',
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: async (context: CommandContext) => {
// Delegate to main action with 'detail' arg to show detailed view
await contextCommand.action!(context, 'detail');

View file

@ -15,6 +15,7 @@ export const copyCommand: SlashCommand = {
return t('Copy the last result or code snippet to clipboard');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
const chat = await context.services.config?.getGeminiClient()?.getChat();
const history = chat?.getHistory();

View file

@ -74,6 +74,7 @@ export const directoryCommand: SlashCommand = {
return t('Manage workspace directories');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
subCommands: [
{
name: 'add',
@ -83,6 +84,7 @@ export const directoryCommand: SlashCommand = {
);
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
completion: async (_context: CommandContext, partialArg: string) =>
getDirPathCompletions(partialArg),
action: async (context: CommandContext, args: string) => {
@ -222,6 +224,7 @@ export const directoryCommand: SlashCommand = {
return t('Show all directories in the workspace');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (context: CommandContext) => {
const {
ui: { addItem },

View file

@ -20,6 +20,7 @@ export const docsCommand: SlashCommand = {
return t('open full Qwen Code documentation in your browser');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (context: CommandContext): Promise<void> => {
const langPath = getCurrentLanguage()?.startsWith('zh') ? 'zh' : 'en';
const docsUrl = `https://qwenlm.github.io/qwen-code-docs/${langPath}`;

View file

@ -17,6 +17,7 @@ export const editorCommand: SlashCommand = {
return t('set external editor preference');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'editor',

View file

@ -325,6 +325,7 @@ export const exportCommand: SlashCommand = {
return t('Export current session message history to a file');
},
kind: CommandKind.BUILT_IN,
commandType: 'local',
subCommands: [
{
name: 'html',
@ -332,6 +333,7 @@ export const exportCommand: SlashCommand = {
return t('Export session to HTML format');
},
kind: CommandKind.BUILT_IN,
commandType: 'local',
action: exportHtmlAction,
},
{
@ -340,6 +342,7 @@ export const exportCommand: SlashCommand = {
return t('Export session to markdown format');
},
kind: CommandKind.BUILT_IN,
commandType: 'local',
action: exportMarkdownAction,
},
{
@ -348,6 +351,7 @@ export const exportCommand: SlashCommand = {
return t('Export session to JSON format');
},
kind: CommandKind.BUILT_IN,
commandType: 'local',
action: exportJsonAction,
},
{
@ -356,6 +360,7 @@ export const exportCommand: SlashCommand = {
return t('Export session to JSONL format (one message per line)');
},
kind: CommandKind.BUILT_IN,
commandType: 'local',
action: exportJsonlAction,
},
],

View file

@ -216,6 +216,7 @@ const exploreExtensionsCommand: SlashCommand = {
return t('Open extensions page in your browser');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: exploreAction,
completion: completeExtensionsExplore,
};
@ -226,6 +227,7 @@ const manageExtensionsCommand: SlashCommand = {
return t('Manage installed extensions');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: listAction,
};
@ -235,6 +237,7 @@ const installCommand: SlashCommand = {
return t('Install an extension from a git repo or local path');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: installAction,
};
@ -244,6 +247,7 @@ export const extensionsCommand: SlashCommand = {
return t('Manage extensions');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
subCommands: [
manageExtensionsCommand,
installCommand,

View file

@ -13,6 +13,7 @@ export const helpCommand: SlashCommand = {
name: 'help',
altNames: ['?'],
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
get description() {
return t('for help on Qwen Code');
},

View file

@ -43,6 +43,7 @@ const listCommand: SlashCommand = {
return t('List all configured hooks');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (
context: CommandContext,
_args: string,
@ -185,6 +186,7 @@ export const hooksCommand: SlashCommand = {
return t('Manage Qwen Code hooks');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (
context: CommandContext,
args: string,

View file

@ -143,6 +143,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
return t('manage IDE integration');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: (): SlashCommandActionReturn =>
({
type: 'message',
@ -160,6 +161,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
return t('manage IDE integration');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
subCommands: [],
};
@ -169,6 +171,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
return t('check status of IDE integration');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (): Promise<SlashCommandActionReturn> => {
const { messageType, content } =
await getIdeStatusMessageWithFiles(ideClient);
@ -189,6 +192,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
});
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (context) => {
const installer = getIdeInstaller(currentIDE);
const isSandBox = !!process.env['SANDBOX'];
@ -276,6 +280,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
return t('enable IDE integration');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (context: CommandContext) => {
context.services.settings.setValue(
SettingScope.User,
@ -300,6 +305,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
return t('disable IDE integration');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (context: CommandContext) => {
context.services.settings.setValue(
SettingScope.User,

View file

@ -23,6 +23,8 @@ export const initCommand: SlashCommand = {
return t('Analyzes the project and creates a tailored QWEN.md file.');
},
kind: CommandKind.BUILT_IN,
commandType: 'local',
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: async (
context: CommandContext,
_args: string,

View file

@ -29,6 +29,7 @@ export const insightCommand: SlashCommand = {
);
},
kind: CommandKind.BUILT_IN,
commandType: 'local',
action: async (context: CommandContext) => {
try {
context.ui.setDebugMessage(t('Generating insights...'));

View file

@ -183,6 +183,7 @@ export const languageCommand: SlashCommand = {
return t('View or change the language setting');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (
context: CommandContext,
@ -268,6 +269,7 @@ export const languageCommand: SlashCommand = {
return t('Set UI language');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (
context: CommandContext,
@ -322,6 +324,7 @@ export const languageCommand: SlashCommand = {
});
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (context, args) => {
if (args.trim()) {
return {
@ -345,6 +348,7 @@ export const languageCommand: SlashCommand = {
return t('Set LLM output language');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (
context: CommandContext,

View file

@ -14,6 +14,7 @@ export const mcpCommand: SlashCommand = {
return t('Open MCP management dialog');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (): Promise<OpenDialogActionReturn> => ({
type: 'dialog',
dialog: 'mcp',

View file

@ -21,19 +21,4 @@ describe('memoryCommand', () => {
dialog: 'memory',
});
});
it('returns a non-interactive fallback message outside the interactive UI', async () => {
const context = createMockCommandContext({
executionMode: 'non_interactive',
});
const result = await memoryCommand.action?.(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'The memory manager is only available in the interactive UI. In non-interactive mode, open the user or project memory files directly.',
});
});
});

View file

@ -14,22 +14,9 @@ export const memoryCommand: SlashCommand = {
return t('Open the memory manager.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
const executionMode = context.executionMode ?? 'interactive';
if (executionMode === 'interactive') {
return {
type: 'dialog',
dialog: 'memory',
};
}
return {
type: 'message',
messageType: 'info',
content: t(
'The memory manager is only available in the interactive UI. In non-interactive mode, open the user or project memory files directly.',
),
};
},
commandType: 'local-jsx',
action: async () => ({
type: 'dialog',
dialog: 'memory',
}),
};

View file

@ -21,6 +21,7 @@ export const modelCommand: SlashCommand = {
return t('Switch the model for this session (--fast for suggestion model)');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
completion: async (_context, partialArg) => {
if (partialArg && '--fast'.startsWith(partialArg)) {
return [

View file

@ -14,6 +14,7 @@ export const permissionsCommand: SlashCommand = {
return t('Manage permission rules');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'permissions',

View file

@ -20,6 +20,7 @@ export const planCommand: SlashCommand = {
return t('Switch to plan mode or exit plan mode');
},
kind: CommandKind.BUILT_IN,
commandType: 'local',
action: async (
context: CommandContext,
args: string,

View file

@ -15,6 +15,7 @@ export const quitCommand: SlashCommand = {
return t('exit the cli');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: (context) => {
const now = Date.now();
const { sessionStartTime } = context.session.stats;

View file

@ -151,6 +151,7 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => {
);
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: restoreAction,
completion,
};

View file

@ -11,6 +11,7 @@ import { t } from '../../i18n/index.js';
export const resumeCommand: SlashCommand = {
name: 'resume',
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
get description() {
return t('Resume a previous session');
},

View file

@ -14,6 +14,7 @@ export const settingsCommand: SlashCommand = {
return t('View and edit Qwen Code settings');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'settings',

View file

@ -99,6 +99,7 @@ export const setupGithubCommand: SlashCommand = {
return t('Set up GitHub Actions');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (
context: CommandContext,
): Promise<SlashCommandActionReturn> => {

View file

@ -24,6 +24,7 @@ export const skillsCommand: SlashCommand = {
return t('List available skills.');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (context: CommandContext, args?: string) => {
const rawArgs = args?.trim() ?? '';
const [skillName = ''] = rawArgs.split(/\s+/);

View file

@ -21,6 +21,7 @@ export const statsCommand: SlashCommand = {
return t('check session stats. Usage: /stats [model|tools]');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: (context: CommandContext) => {
const now = new Date();
const { sessionStartTime } = context.session.stats;
@ -50,6 +51,7 @@ export const statsCommand: SlashCommand = {
return t('Show model-specific usage statistics.');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: (context: CommandContext) => {
context.ui.addItem(
{
@ -65,6 +67,7 @@ export const statsCommand: SlashCommand = {
return t('Show tool-specific usage statistics.');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: (context: CommandContext) => {
context.ui.addItem(
{

View file

@ -14,6 +14,7 @@ export const statuslineCommand: SlashCommand = {
return t("Set up Qwen Code's status line UI");
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: (_context, args): SubmitPromptActionReturn => {
const prompt =
args.trim() || 'Configure my statusLine from my shell PS1 configuration';

View file

@ -23,6 +23,8 @@ export const summaryCommand: SlashCommand = {
);
},
kind: CommandKind.BUILT_IN,
commandType: 'local',
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: async (context): Promise<SlashCommandActionReturn> => {
const { config } = context.services;
const { ui } = context;

View file

@ -23,6 +23,7 @@ export const terminalSetupCommand: SlashCommand = {
);
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (): Promise<MessageActionReturn> => {
try {

View file

@ -14,6 +14,7 @@ export const themeCommand: SlashCommand = {
return t('change the theme');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'theme',

View file

@ -18,6 +18,7 @@ export const toolsCommand: SlashCommand = {
return t('list available Qwen Code tools. Usage: /tools [desc]');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (context: CommandContext, args?: string): Promise<void> => {
const subCommand = args?.trim();

View file

@ -14,6 +14,7 @@ export const trustCommand: SlashCommand = {
return t('Manage folder trust settings');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'trust',

View file

@ -235,6 +235,49 @@ export enum CommandKind {
SKILL = 'skill',
}
/**
* Execution mode for a slash command invocation.
* - interactive: React/Ink UI mode (terminal)
* - non_interactive: headless CLI mode (text/JSON output)
* - acp: ACP/Zed editor integration mode
*/
export type ExecutionMode = 'interactive' | 'non_interactive' | 'acp';
/**
* The source of a slash command, used for Help grouping, completion badges,
* and ACP available-command metadata.
*
* Distinct from CommandKind: CommandKind drives loader logic (4 values);
* CommandSource drives display and user mental model (5+ values).
*/
export type CommandSource =
| 'builtin-command' // BuiltinCommandLoader
| 'bundled-skill' // BundledSkillLoader
| 'skill-dir-command' // FileCommandLoader (user/project, no extensionName)
| 'plugin-command' // FileCommandLoader (extension, extensionName set)
| 'mcp-prompt'; // McpPromptLoader
// Reserved for future loaders (not implemented in Phase 1):
// | 'workflow-command'
// | 'plugin-skill'
// | 'dynamic-skill'
/**
* The execution type of a slash command, describing *how* it runs.
*
* - prompt: Produces a submit_prompt content is sent to the model.
* Default supportedModes: all. Default modelInvocable: true.
*
* - local: Runs local logic with no React/Ink UI dependency.
* Can return message, stream_messages, submit_prompt, tool, etc.
* Default supportedModes: ['interactive'] must explicitly declare
* supportedModes to unlock other modes (mirrors Claude Code's
* supportsNonInteractive: true pattern).
*
* - local-jsx: Depends on React/Ink UI (dialogs, JSX components, etc.).
* Default supportedModes: ['interactive'] only.
*/
export type CommandType = 'prompt' | 'local' | 'local-jsx';
export interface CommandCompletionItem {
value: string;
label?: string;
@ -255,6 +298,69 @@ export interface SlashCommand {
// Optional metadata for extension commands
extensionName?: string;
// ── Phase 1: source & execution type ──────────────────────────────────
/**
* The source of this command. Set by the Loader, not by the command itself.
* Will replace CommandKind as the canonical source identifier in a future phase.
*/
source?: CommandSource;
/**
* Human-readable source label for display in Help, completion badges, etc.
* - builtin-command "Built-in"
* - bundled-skill "Skill"
* - skill-dir-command "Custom"
* - plugin-command "Plugin: <extensionName>"
* - mcp-prompt "MCP: <serverName>"
* Set by the Loader; may be overridden by the command itself.
*/
sourceLabel?: string;
/**
* How this command executes. Set by built-in command files (local/local-jsx)
* or by Loaders (prompt). Used by getEffectiveSupportedModes() to infer
* which execution modes are supported.
*/
commandType?: CommandType;
// ── Phase 1: mode capability ───────────────────────────────────────────
/**
* Which execution modes this command is available in.
* Explicit declaration takes priority over commandType inference.
* See getEffectiveSupportedModes() in commandUtils.ts for the full logic.
*/
supportedModes?: ExecutionMode[];
// ── Phase 1: visibility ────────────────────────────────────────────────
/**
* Whether users can invoke this command via a slash command.
* Defaults to true for all commands.
*/
userInvocable?: boolean;
/**
* Whether the model can invoke this command via a tool call.
* Defaults to false. prompt-type commands (skills, file commands, MCP prompts)
* should be true. Built-in commands must always be false.
*/
modelInvocable?: boolean;
// ── Phase 3 reserved: UX metadata (defined now, unused until Phase 3) ─
/**
* Argument hint shown after the command name in the completion menu.
* Example: "<model-id>" / "show|list|set <id>"
*/
argumentHint?: string;
/**
* Describes when to use this command injected into the model-visible
* description for modelInvocable commands.
*/
whenToUse?: string;
/** Usage examples shown in Help and completion. */
examples?: string[];
// The action to run. Optional for parent commands that only group sub-commands.
action?: (
context: CommandContext,

View file

@ -14,6 +14,7 @@ export const vimCommand: SlashCommand = {
return t('toggle vim mode on/off');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (context, _args) => {
const newVimState = await context.ui.toggleVimEnabled();

View file

@ -361,7 +361,7 @@ export const useSlashCommandProcessor = (
);
// Avoid overwriting newer results from a subsequent effect run
if (!controller.signal.aborted) {
setCommands(commandService.getCommands());
setCommands(commandService.getCommandsForMode('interactive'));
}
} catch (error) {
debugLogger.error('Failed to load slash commands:', error);

View file

@ -36,34 +36,50 @@ import {
} from './nonInteractiveHelpers.js';
// Mock dependencies
vi.mock('../nonInteractiveCliCommands.js', () => ({
getAvailableCommands: vi
.fn()
.mockImplementation(
async (
_config: unknown,
_signal: AbortSignal,
allowedBuiltinCommandNames?: string[],
) => {
const allowedSet = new Set(allowedBuiltinCommandNames ?? []);
const allCommands = [
{ name: 'help', kind: 'built-in' },
{ name: 'commit', kind: 'file' },
{ name: 'memory', kind: 'built-in' },
{ name: 'init', kind: 'built-in' },
{ name: 'summary', kind: 'built-in' },
{ name: 'compress', kind: 'built-in' },
];
vi.mock('../nonInteractiveCliCommands.js', async () => {
const { filterCommandsForMode } = await import('../services/commandUtils.js');
return {
getAvailableCommands: vi
.fn()
.mockImplementation(
async (
_config: unknown,
_signal: AbortSignal,
mode: string = 'acp',
) => {
// Simulate capability-based filtering with commandType / supportedModes
// Delegate to production filterCommandsForMode to avoid logic divergence
const allCommands = [
{ name: 'help', commandType: 'local-jsx' },
{ name: 'commit', commandType: 'prompt' },
{ name: 'memory', commandType: 'local' },
{
name: 'init',
commandType: 'local',
supportedModes: ['interactive', 'non_interactive', 'acp'],
},
{
name: 'summary',
commandType: 'local',
supportedModes: ['interactive', 'non_interactive', 'acp'],
},
{
name: 'compress',
commandType: 'local',
supportedModes: ['interactive', 'non_interactive', 'acp'],
},
];
// Filter commands: always include file commands, only include allowed built-in commands
return allCommands.filter(
(cmd) =>
cmd.kind === 'file' ||
(cmd.kind === 'built-in' && allowedSet.has(cmd.name)),
);
},
),
}));
return filterCommandsForMode(
allCommands as unknown as Parameters<
typeof filterCommandsForMode
>[0],
mode as Parameters<typeof filterCommandsForMode>[1],
);
},
),
};
});
vi.mock('../ui/utils/computeStats.js', () => ({
computeSessionStats: vi.fn().mockReturnValue({
@ -520,12 +536,10 @@ describe('buildSystemMessage', () => {
});
it('should build system message with all fields', async () => {
const allowedBuiltinCommands = ['init', 'summary', 'compress'];
const result = await buildSystemMessage(
mockConfig,
'test-session-id',
'auto' as PermissionMode,
allowedBuiltinCommands,
);
expect(result).toEqual({
@ -557,7 +571,6 @@ describe('buildSystemMessage', () => {
config,
'test-session-id',
'auto' as PermissionMode,
['init', 'summary'],
);
expect(result.tools).toEqual([]);
@ -573,7 +586,6 @@ describe('buildSystemMessage', () => {
config,
'test-session-id',
'auto' as PermissionMode,
['init', 'summary'],
);
expect(result.mcp_servers).toEqual([]);
@ -589,36 +601,38 @@ describe('buildSystemMessage', () => {
config,
'test-session-id',
'auto' as PermissionMode,
['init', 'summary'],
);
expect(result.qwen_code_version).toBe('unknown');
});
it('should only include allowed built-in commands and all file commands', async () => {
const allowedBuiltinCommands = ['init', 'summary'];
it('should include local commands with ACP supportedModes and prompt commands', async () => {
const result = await buildSystemMessage(
mockConfig,
'test-session-id',
'auto' as PermissionMode,
allowedBuiltinCommands,
);
// Should include: 'commit' (FILE), 'init' (BUILT_IN, allowed), 'summary' (BUILT_IN, allowed)
// Should NOT include: 'help', 'memory', 'compress' (BUILT_IN but not in allowed set)
expect(result.slash_commands).toEqual(['commit', 'init', 'summary']);
// Should include: 'commit' (prompt), 'compress', 'init', 'summary' (local+ACP)
// Should NOT include: 'help' (local-jsx), 'memory' (local without ACP supportedModes)
expect(result.slash_commands).toEqual([
'commit',
'compress',
'init',
'summary',
]);
});
it('should include only file commands when no built-in commands are allowed', async () => {
it('should exclude interactive-only commands from system message', async () => {
const result = await buildSystemMessage(
mockConfig,
'test-session-id',
'auto' as PermissionMode,
[], // Empty array - no built-in commands allowed
);
// Should only include 'commit' (FILE command)
expect(result.slash_commands).toEqual(['commit']);
// 'help' (local-jsx) and 'memory' (local without ACP) should be excluded
expect(result.slash_commands).not.toContain('help');
expect(result.slash_commands).not.toContain('memory');
});
});

View file

@ -196,20 +196,15 @@ export function computeUsageFromMetrics(metrics: SessionMetrics): Usage {
* Load slash command names using getAvailableCommands
*
* @param config - Config instance
* @param allowedBuiltinCommandNames - Optional array of allowed built-in command names.
* If not provided, uses the default from getAvailableCommands.
* @returns Promise resolving to array of slash command names
*/
async function loadSlashCommandNames(
config: Config,
allowedBuiltinCommandNames?: string[],
): Promise<string[]> {
async function loadSlashCommandNames(config: Config): Promise<string[]> {
const controller = new AbortController();
try {
const commands = await getAvailableCommands(
config,
controller.signal,
allowedBuiltinCommandNames,
'non_interactive',
);
// Extract command names and sort
@ -240,15 +235,12 @@ async function loadSlashCommandNames(
* @param config - Config instance
* @param sessionId - Session identifier
* @param permissionMode - Current permission/approval mode
* @param allowedBuiltinCommandNames - Optional array of allowed built-in command names.
* If not provided, defaults to empty array (only file commands will be included).
* @returns Promise resolving to CLISystemMessage
*/
export async function buildSystemMessage(
config: Config,
sessionId: string,
permissionMode: PermissionMode,
allowedBuiltinCommandNames?: string[],
): Promise<CLISystemMessage> {
const toolRegistry = config.getToolRegistry();
const tools = toolRegistry ? toolRegistry.getAllToolNames() : [];
@ -261,11 +253,8 @@ export async function buildSystemMessage(
}))
: [];
// Load slash commands with filtering based on allowed built-in commands
const slashCommands = await loadSlashCommandNames(
config,
allowedBuiltinCommandNames,
);
// Load slash commands available in ACP mode
const slashCommands = await loadSlashCommandNames(config);
// Load subagent names from config
let agentNames: string[] = [];