mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
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:
parent
6c999fe29f
commit
a82d766727
62 changed files with 2350 additions and 307 deletions
671
docs/design/slash-command/compare.md
Normal file
671
docs/design/slash-command/compare.md
Normal 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
|
||||
|
||||
本方案明确选择后者。
|
||||
765
docs/design/slash-command/phase1-technical-design.md
Normal file
765
docs/design/slash-command/phase1-technical-design.md
Normal 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
|
||||
/**
|
||||
* 运行模式枚举。
|
||||
* - interactive:React/Ink UI 模式(终端交互)
|
||||
* - non_interactive:无交互 CLI 模式(文本/JSON 输出)
|
||||
* - acp:ACP/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' // 随包分发的 skill(BundledSkillLoader)
|
||||
| 'skill-dir-command' // 用户/项目 .qwen/commands/ 下的文件命令(FileCommandLoader,非插件)
|
||||
| 'plugin-command' // 插件提供的命令(FileCommandLoader,extensionName 不为空)
|
||||
| 'mcp-prompt'; // MCP server 提供的 prompt(McpPromptLoader)
|
||||
// 以下来源预留,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 不处理 hidden,CommandService 处理)', () => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
### 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**(~30min):CR 自查:确认白名单已完全移除,无遗漏调用
|
||||
|
||||
---
|
||||
|
||||
## 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 模式下不再被错误拦截
|
||||
263
docs/design/slash-command/roadmap.md
Normal file
263
docs/design/slash-command/roadmap.md
Normal 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` 不带子命令时仍然打开 dialog(interactive 模式)。新增子命令是 **在现有命令上追加**,不是替换。
|
||||
|
||||
#### 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 slash:ghost 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 不互相依赖,可以并行推进(或根据优先级调换部分子项)。
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
212
packages/cli/src/services/commandUtils.test.ts
Normal file
212
packages/cli/src/services/commandUtils.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
91
packages/cli/src/services/commandUtils.ts
Normal file
91
packages/cli/src/services/commandUtils.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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...'));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: restoreAction,
|
||||
completion,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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+/);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const terminalSetupCommand: SlashCommand = {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
|
||||
action: async (): Promise<MessageActionReturn> => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue