diff --git a/docs/design/slash-command/compare.md b/docs/design/slash-command/compare.md new file mode 100644 index 000000000..1c4226fae --- /dev/null +++ b/docs/design/slash-command/compare.md @@ -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 ` + +#### `/permissions` + +- `/permissions` +- `/permissions show` +- `/permissions set ` +- `/permissions allow ` +- `/permissions deny ` + +#### `/mcp` + +- `/mcp` +- `/mcp list` +- `/mcp show ` +- `/mcp enable ` +- `/mcp disable ` + +--- + +## 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 + +本方案明确选择后者。 diff --git a/docs/design/slash-command/phase1-technical-design.md b/docs/design/slash-command/phase1-technical-design.md new file mode 100644 index 000000000..ef25b86ff --- /dev/null +++ b/docs/design/slash-command/phase1-technical-design.md @@ -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: " + * - mcp-prompt → "MCP: " + * 由各 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 仅定义,不使用)────────────────── + /** + * 参数提示,显示在补全菜单命令名后。 + * 示例:"" / "show|list|set " / "[--fast] []" + */ + 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, +): 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 + +// ✅ 新签名(移除 allowedBuiltinCommandNames) +export const handleSlashCommand = async ( + rawQuery: string, + abortController: AbortController, + config: Config, + settings: LoadedSettings, +): Promise +``` + +### 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 + +// ✅ 新签名 +export const getAvailableCommands = async ( + config: Config, + abortSignal: AbortSignal, + mode: ExecutionMode = 'acp', +): Promise +``` + +> 新增 `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 模式下不再被错误拦截 diff --git a/docs/design/slash-command/roadmap.md b/docs/design/slash-command/roadmap.md new file mode 100644 index 000000000..b62e79ff1 --- /dev/null +++ b/docs/design/slash-command/roadmap.md @@ -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`:参数提示,如 `""` / `"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 `(切换) | +| `/permissions` | `show`(当前权限模式)、`set `(设置) | +| `/mcp` | `list`(MCP 服务列表)、`show `(服务详情)、`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 ` 在 non-interactive / acp 下可执行 +- [ ] `/permissions show`、`/permissions set ` 在 non-interactive / acp 下可执行 +- [ ] `/mcp list`、`/mcp show ` 在 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 `) +- `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 `) +- [ ] 近期使用的命令在补全列表中优先出现 +- [ ] 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 不互相依赖,可以并行推进(或根据优先级调换部分子项)。 diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index b9661ff64..740e84eb3 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -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', diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 9fed475ec..e905f9c6b 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -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 { 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 diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index d17db7f27..5d06b57fb 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -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 []; diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 840dc6424..33e4dff96 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -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', ); }); diff --git a/packages/cli/src/nonInteractiveCliCommands.test.ts b/packages/cli/src/nonInteractiveCliCommands.test.ts index aed49c0b8..1e83abcad 100644 --- a/packages/cli/src/nonInteractiveCliCommands.test.ts +++ b/packages/cli/src/nonInteractiveCliCommands.test.ts @@ -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'); diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 3ce8d155a..5a5fc69ac 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -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, -): 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 => { 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(); - 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 => { 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); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 2ed1fab9d..05bd26981 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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, + })); } } diff --git a/packages/cli/src/services/BundledSkillLoader.ts b/packages/cli/src/services/BundledSkillLoader.ts index 2f15a4603..faa39910d 100644 --- a/packages/cli/src/services/BundledSkillLoader.ts +++ b/packages/cli/src/services/BundledSkillLoader.ts @@ -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 => { // Resolve template variables in skill body let body = skill.body; diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index 832267c7b..d4bcc6641 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -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, + ); + } } diff --git a/packages/cli/src/services/McpPromptLoader.ts b/packages/cli/src/services/McpPromptLoader.ts index 36da96d6c..c18e67e88 100644 --- a/packages/cli/src/services/McpPromptLoader.ts +++ b/packages/cli/src/services/McpPromptLoader.ts @@ -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', diff --git a/packages/cli/src/services/command-factory.ts b/packages/cli/src/services/command-factory.ts index 1720401e1..9293e97ac 100644 --- a/packages/cli/src/services/command-factory.ts +++ b/packages/cli/src/services/command-factory.ts @@ -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, diff --git a/packages/cli/src/services/commandUtils.test.ts b/packages/cli/src/services/commandUtils.test.ts new file mode 100644 index 000000000..0287e0a2a --- /dev/null +++ b/packages/cli/src/services/commandUtils.test.ts @@ -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 { + 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([]); + }); +}); diff --git a/packages/cli/src/services/commandUtils.ts b/packages/cli/src/services/commandUtils.ts new file mode 100644 index 000000000..ddd60aae3 --- /dev/null +++ b/packages/cli/src/services/commandUtils.ts @@ -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), + ); +} diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index 1570b9e3d..60fce9f96 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -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); diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts index 02fed007b..94b8d2cb0 100644 --- a/packages/cli/src/ui/commands/agentsCommand.ts +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -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', diff --git a/packages/cli/src/ui/commands/approvalModeCommand.ts b/packages/cli/src/ui/commands/approvalModeCommand.ts index f41e4b1cf..b66cc8a38 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.ts @@ -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, diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index c178a021d..f5fbb04c4 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -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 => { @@ -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 => { @@ -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, diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts index 83ab454b0..e7abf552f 100644 --- a/packages/cli/src/ui/commands/authCommand.ts +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -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', diff --git a/packages/cli/src/ui/commands/btwCommand.ts b/packages/cli/src/ui/commands/btwCommand.ts index 8514a857a..80d0c6260 100644 --- a/packages/cli/src/ui/commands/btwCommand.ts +++ b/packages/cli/src/ui/commands/btwCommand.ts @@ -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, diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 23f97798c..543741b13 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -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 => { const bugDescription = (args || '').trim(); const systemInfo = await getExtendedSystemInfo(context); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index c0c2bc5ea..f55b3beed 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -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; diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index cdd07d45d..b3cbd8c8b 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -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'; diff --git a/packages/cli/src/ui/commands/contextCommand.ts b/packages/cli/src/ui/commands/contextCommand.ts index 499670995..67a4fc611 100644 --- a/packages/cli/src/ui/commands/contextCommand.ts +++ b/packages/cli/src/ui/commands/contextCommand.ts @@ -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'); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 421b0323b..4eec750cc 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -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 => { const chat = await context.services.config?.getGeminiClient()?.getChat(); const history = chat?.getHistory(); diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index f404a0bf6..70acf6463 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -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 }, diff --git a/packages/cli/src/ui/commands/docsCommand.ts b/packages/cli/src/ui/commands/docsCommand.ts index 8fc018362..bde817c7e 100644 --- a/packages/cli/src/ui/commands/docsCommand.ts +++ b/packages/cli/src/ui/commands/docsCommand.ts @@ -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 => { const langPath = getCurrentLanguage()?.startsWith('zh') ? 'zh' : 'en'; const docsUrl = `https://qwenlm.github.io/qwen-code-docs/${langPath}`; diff --git a/packages/cli/src/ui/commands/editorCommand.ts b/packages/cli/src/ui/commands/editorCommand.ts index f39cbdbca..54a4097a9 100644 --- a/packages/cli/src/ui/commands/editorCommand.ts +++ b/packages/cli/src/ui/commands/editorCommand.ts @@ -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', diff --git a/packages/cli/src/ui/commands/exportCommand.ts b/packages/cli/src/ui/commands/exportCommand.ts index 755a7061e..a7cc8f8bc 100644 --- a/packages/cli/src/ui/commands/exportCommand.ts +++ b/packages/cli/src/ui/commands/exportCommand.ts @@ -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, }, ], diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 99667959b..a8661400a 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -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, diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts index c4772ea08..0c4d528a3 100644 --- a/packages/cli/src/ui/commands/helpCommand.ts +++ b/packages/cli/src/ui/commands/helpCommand.ts @@ -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'); }, diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index 49902994d..f6b28fefe 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -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, diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index be3a12bc9..9d75fcc34 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -143,6 +143,7 @@ export const ideCommand = async (): Promise => { 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 => { return t('manage IDE integration'); }, kind: CommandKind.BUILT_IN, + commandType: 'local-jsx', subCommands: [], }; @@ -169,6 +171,7 @@ export const ideCommand = async (): Promise => { return t('check status of IDE integration'); }, kind: CommandKind.BUILT_IN, + commandType: 'local-jsx', action: async (): Promise => { const { messageType, content } = await getIdeStatusMessageWithFiles(ideClient); @@ -189,6 +192,7 @@ export const ideCommand = async (): Promise => { }); }, 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 => { 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 => { return t('disable IDE integration'); }, kind: CommandKind.BUILT_IN, + commandType: 'local-jsx', action: async (context: CommandContext) => { context.services.settings.setValue( SettingScope.User, diff --git a/packages/cli/src/ui/commands/initCommand.ts b/packages/cli/src/ui/commands/initCommand.ts index 16c98dfff..cf6af99bb 100644 --- a/packages/cli/src/ui/commands/initCommand.ts +++ b/packages/cli/src/ui/commands/initCommand.ts @@ -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, diff --git a/packages/cli/src/ui/commands/insightCommand.ts b/packages/cli/src/ui/commands/insightCommand.ts index 7bd8d113d..5c957d1df 100644 --- a/packages/cli/src/ui/commands/insightCommand.ts +++ b/packages/cli/src/ui/commands/insightCommand.ts @@ -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...')); diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index 6ddade4fb..c459959be 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -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, diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 4ca165e35..a751c5abd 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -14,6 +14,7 @@ export const mcpCommand: SlashCommand = { return t('Open MCP management dialog'); }, kind: CommandKind.BUILT_IN, + commandType: 'local-jsx', action: async (): Promise => ({ type: 'dialog', dialog: 'mcp', diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 001b9de2b..5836cf856 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -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.', - }); - }); }); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index be2dd72be..fc10061a3 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -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', + }), }; diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index c80e26b18..c0af230bd 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -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 [ diff --git a/packages/cli/src/ui/commands/permissionsCommand.ts b/packages/cli/src/ui/commands/permissionsCommand.ts index 034fec843..f1ab21915 100644 --- a/packages/cli/src/ui/commands/permissionsCommand.ts +++ b/packages/cli/src/ui/commands/permissionsCommand.ts @@ -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', diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts index 494ee463f..6dba0f0c6 100644 --- a/packages/cli/src/ui/commands/planCommand.ts +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -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, diff --git a/packages/cli/src/ui/commands/quitCommand.ts b/packages/cli/src/ui/commands/quitCommand.ts index 4e9da3a0c..9d7f7623d 100644 --- a/packages/cli/src/ui/commands/quitCommand.ts +++ b/packages/cli/src/ui/commands/quitCommand.ts @@ -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; diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts index 72d83c5aa..051627c5f 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -151,6 +151,7 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => { ); }, kind: CommandKind.BUILT_IN, + commandType: 'local-jsx', action: restoreAction, completion, }; diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts index 20592bf39..4f0fa7dd1 100644 --- a/packages/cli/src/ui/commands/resumeCommand.ts +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -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'); }, diff --git a/packages/cli/src/ui/commands/settingsCommand.ts b/packages/cli/src/ui/commands/settingsCommand.ts index f7052f192..0f9e79344 100644 --- a/packages/cli/src/ui/commands/settingsCommand.ts +++ b/packages/cli/src/ui/commands/settingsCommand.ts @@ -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', diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 40bec554f..2102d30d7 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -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 => { diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index c154a479e..79b45e4e5 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -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+/); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index cb4a3f512..0ee487ecc 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -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( { diff --git a/packages/cli/src/ui/commands/statuslineCommand.ts b/packages/cli/src/ui/commands/statuslineCommand.ts index 5e0f1f110..759503e46 100644 --- a/packages/cli/src/ui/commands/statuslineCommand.ts +++ b/packages/cli/src/ui/commands/statuslineCommand.ts @@ -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'; diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index 5e84bf53e..eaf011d7e 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -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 => { const { config } = context.services; const { ui } = context; diff --git a/packages/cli/src/ui/commands/terminalSetupCommand.ts b/packages/cli/src/ui/commands/terminalSetupCommand.ts index 3fb854466..22888fd90 100644 --- a/packages/cli/src/ui/commands/terminalSetupCommand.ts +++ b/packages/cli/src/ui/commands/terminalSetupCommand.ts @@ -23,6 +23,7 @@ export const terminalSetupCommand: SlashCommand = { ); }, kind: CommandKind.BUILT_IN, + commandType: 'local-jsx', action: async (): Promise => { try { diff --git a/packages/cli/src/ui/commands/themeCommand.ts b/packages/cli/src/ui/commands/themeCommand.ts index fd366366a..5761ee13e 100644 --- a/packages/cli/src/ui/commands/themeCommand.ts +++ b/packages/cli/src/ui/commands/themeCommand.ts @@ -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', diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts index 4bd97e3ec..49123623f 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -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 => { const subCommand = args?.trim(); diff --git a/packages/cli/src/ui/commands/trustCommand.ts b/packages/cli/src/ui/commands/trustCommand.ts index 9fa566db2..5a213cc2b 100644 --- a/packages/cli/src/ui/commands/trustCommand.ts +++ b/packages/cli/src/ui/commands/trustCommand.ts @@ -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', diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 39da70f5c..f851857c9 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -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: " + * - mcp-prompt → "MCP: " + * 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: "" / "show|list|set " + */ + 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, diff --git a/packages/cli/src/ui/commands/vimCommand.ts b/packages/cli/src/ui/commands/vimCommand.ts index 8f3dc6bd0..28e30806c 100644 --- a/packages/cli/src/ui/commands/vimCommand.ts +++ b/packages/cli/src/ui/commands/vimCommand.ts @@ -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(); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index ca7f500a5..4ccba4192 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -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); diff --git a/packages/cli/src/utils/nonInteractiveHelpers.test.ts b/packages/cli/src/utils/nonInteractiveHelpers.test.ts index a0f3c3a00..b69056707 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.test.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.test.ts @@ -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[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'); }); }); diff --git a/packages/cli/src/utils/nonInteractiveHelpers.ts b/packages/cli/src/utils/nonInteractiveHelpers.ts index 033b57460..8e3ade4ff 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.ts @@ -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 { +async function loadSlashCommandNames(config: Config): Promise { 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 { 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[] = [];