diff --git a/docs/design/slash-command/phase2-technical-design.md b/docs/design/slash-command/phase2-technical-design.md new file mode 100644 index 000000000..2e45b9da4 --- /dev/null +++ b/docs/design/slash-command/phase2-technical-design.md @@ -0,0 +1,688 @@ +# Phase 2 技术设计文档:能力扩展 + +## 1. 设计目标与约束 + +### 1.1 目标 + +- 将 13 个 built-in 命令的 `supportedModes` 扩展到包含 `non_interactive` 和/或 `acp` +- 确保每个扩展命令在 ACP/non-interactive 路径下返回适合 IDE 消费的文本内容 +- 打通 prompt command 的模型调用通路(`SkillTool` 消费 `getModelInvocableCommands()`) +- 实现 mid-input slash command 基础检测 + +### 1.2 硬性约束 + +- **interactive 路径零退化**:所有扩展命令的现有 interactive 行为严格不变,只在 action 内部新增模式分支,不触碰 interactive 路径代码 +- **实现策略:模式分支,而非双注册**:13 个命令均采用在 `action` 内部增加 `executionMode` 判断的方式,不使用 Phase 1 设计文档 §10.2 描述的双注册模式(双注册仅在 interactive 和 non-interactive 逻辑差异极大时才有必要,本阶段命令复杂度不达到该门槛) +- **ACP 消息格式**:ACP 路径返回的文本内容不含 ANSI 样式,以 Markdown 或纯文本为宜,面向 IDE 插件消费 +- **跳过环境相关副作用**:打开浏览器(`open()`)、操作剪贴板(`copyToClipboard()`)等依赖图形环境的操作,在 non-interactive/ACP 路径下必须跳过 + +--- + +## 2. Phase 1 完成后的基础状态 + +Phase 1 结束后的架构要点(Phase 2 直接在此基础上扩展): + +- `commandType` 字段已从 `SlashCommand` 接口中删除,所有命令改用显式 `supportedModes` +- `getEffectiveSupportedModes()` 为两级推断:显式 `supportedModes` → `CommandKind` 兜底 +- `CommandService.getCommandsForMode(mode)` 取代原 `ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE` 白名单 +- `btw`、`bug`、`compress`、`context`、`init`、`summary` 已在 Phase 1 中扩展到全模式,**不在本阶段列表中** +- `createNonInteractiveUI()` 中各方法均为 no-op:`addItem`、`clear`、`setDebugMessage`、`setPendingItem`、`reloadCommands` 均静默忽略调用 + +--- + +## 3. 变更范围总览 + +本阶段共涉及 13 个命令,按实现复杂度分为四类: + +| 类别 | 命令 | 变更要点 | +| ---------- | -------------------------------------------- | ------------------------------------------------------------------------------------ | +| **A 类** | `export` | 只改 `supportedModes`,action 所有路径已返回合法类型 | +| **仅交互** | `plan`、`statusline` | 设计决策:这两个命令语义上与交互界面紧密耦合,保持 `supportedModes: ['interactive']` | +| **A+ 类** | `language` | 改 `supportedModes` + 少量 non-interactive 分支处理 | +| **仅交互** | `copy`、`restore` | 设计决策:剥贴板和快照恢复本质上是交互操作,保持 `supportedModes: ['interactive']` | +| **A' 类** | `model`、`approval-mode` | 有参数路径已返回 `message`,无参数路径需新增 non-interactive 分支(现触发 dialog) | +| **B 类** | `about`、`stats`、`insight`、`docs`、`clear` | action 所有路径均无返回值或调用 `addItem`/`clear`,需新增完整 non-interactive 分支 | + +--- + +## 4. A 类:只改 `supportedModes` + +这三个命令的所有 `action` 路径已经返回 `message` 或 `submit_prompt`,完全无 UI 依赖,`handleCommandResult` 可直接处理。 + +### 4.1 `/export`(及子命令) + +**当前状态**:`supportedModes: ['interactive']`,所有子命令 action 均返回 `MessageActionReturn`。 + +**变更**:将父命令及所有四个子命令(`md`、`html`、`json`、`jsonl`)的 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 + +**ACP 消息内容**:action 现有返回内容已包含完整文件路径(如 `Session exported to markdown: qwen-export-2024-01-01T12-00-00.md`),对 IDE 消费友好,无需修改文本。 + +> **注意**:`/export` 父命令本身没有 `action`,只有子命令。将父命令 `supportedModes` 改为全模式后,`parseSlashCommand` 能够匹配子命令路由,但若用户只输入 `/export` 不带子命令,`commandToExecute.action` 为 undefined,`handleSlashCommand` 返回 `no_command`,调用方会显示可用子命令提示。这是预期行为。 + +### 4.2 `/plan` + +**当前状态**:`supportedModes: ['interactive']`,action 所有路径返回 `MessageActionReturn` 或 `SubmitPromptActionReturn`。 + +**设计决策**:`/plan` 是引导用户进行多轮交互规划的命令,语义上与交互界面紧密耦合。经讨论决定保持 `supportedModes: ['interactive']`,不扩展至 non-interactive/acp 模式。 + +### 4.3 `/statusline` + +**当前状态**:`supportedModes: ['interactive']`,action 始终返回 `SubmitPromptActionReturn`(将 subagent 调用 prompt 提交给模型)。 + +**设计决策**:`/statusline` 是触发 subagent 对当前状态进行总结的命令,语义上与交互界面紧密耦合。经讨论决定保持 `supportedModes: ['interactive']`,不扩展至 non-interactive/acp 模式。 + +--- + +## 5. A+ 类:少量 non-interactive 分支处理 + +### 5.1 `/language` + +**当前状态**:action 所有路径均返回 `MessageActionReturn`(读取/设置语言设置)。 + +**需要处理的副作用**:`setUiLanguage()` 内调用 `context.ui.reloadCommands()`,在非交互 UI 中已是 no-op,无需额外处理。 + +**变更**: + +- 将父命令及子命令(`ui`、`output`,以及 `SUPPORTED_LANGUAGES` 动态生成的子命令)的 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +- action 无需添加模式分支,现有返回文本已适合机器消费。 + +**ACP 语义说明**:在 non-interactive(单次调用)中执行 `/language ui zh-CN` 会修改持久化设置(写入 settings 文件),该变更对后续 session 生效,本次 session 内 i18n 也立即生效。这与用户预期一致。 + +### 5.2 `/copy` + +**当前状态**:action 调用 `copyToClipboard()`,在 ACP/headless 环境中可能抛出异常或无声失败(clipboard 不可用)。 + +**变更**: + +1. 将 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 在 action 内新增模式分支: + +```typescript +// 获取 last AI message(现有逻辑,可复用) +if (context.executionMode !== 'interactive') { + // 非交互/ACP:跳过剪贴板,返回内容本身 + if (!lastAiOutput) { + return { + type: 'message', + messageType: 'info', + content: 'No output in history.', + }; + } + return { + type: 'message', + messageType: 'info', + content: lastAiOutput, + }; +} +// interactive 路径:原有剪贴板逻辑不变 +await copyToClipboard(lastAiOutput); +return { + type: 'message', + messageType: 'info', + content: 'Last output copied to the clipboard', +}; +``` + +**ACP 语义**:IDE 收到最后一条模型输出的原文,可自行决定是否写入剪贴板或展示给用户。 + +### 5.3 `/restore` + +**当前状态**:`supportedModes: ['interactive']`。 + +**设计决策**:快照恢复进一步会重新执行工具调用,语义上与交互界面紧密耦合。经讨论决定保持 `supportedModes: ['interactive']`,不扩展至 non-interactive/acp 模式。 + +**ACP 语义**:checkpoint 的 git 状态恢复和 gemini client history 设置均作为副作用执行;IDE 收到确认消息后可提示用户"状态已恢复",工具重执行由 IDE 自行决定是否触发。 + +--- + +## 6. A' 类:无参数 dialog 路径的 non-interactive 处理 + +### 6.1 `/model` + +**当前状态**: + +| 输入 | 当前行为 | +| -------------------------------- | -------------------------------------------------------------------------------- | +| `/model`(无参数) | → `{ type: 'dialog', dialog: 'model' }`(non-interactive 下变 unsupported) | +| `/model ` | 未实现(只有 `--fast` 分支) | +| `/model --fast`(无 model name) | → `{ type: 'dialog', dialog: 'fast-model' }`(non-interactive 下变 unsupported) | +| `/model --fast ` | → `MessageActionReturn` ✅ | + +**变更**: + +1. 将 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 在 action 内各 dialog 路径前插入 non-interactive 分支: + +```typescript +// 无参数路径(原返回 dialog: 'model') +if (!args.trim()) { + if (context.executionMode !== 'interactive') { + const currentModel = config.getModel() ?? 'unknown'; + return { + type: 'message', + messageType: 'info', + content: `Current model: ${currentModel}\nUse "/model " to switch models.`, + }; + } + return { type: 'dialog', dialog: 'model' }; +} + +// --fast 无参数路径(原返回 dialog: 'fast-model') +if (args.startsWith('--fast') && !modelName) { + if (context.executionMode !== 'interactive') { + const fastModel = context.services.settings?.merged?.fastModel ?? 'not set'; + return { + type: 'message', + messageType: 'info', + content: `Current fast model: ${fastModel}\nUse "/model --fast " to set fast model.`, + }; + } + return { type: 'dialog', dialog: 'fast-model' }; +} +``` + +**ACP 语义**:IDE 展示当前模型名称,供用户参考;切换模型通过带参数调用实现(`/model `)。 + +> **注意**:`/model `(不带 `--fast`)目前没有实现设置当前 session 模型的逻辑,只有 `--fast ` 有。如果 Phase 2 要支持 ACP 下切换主模型,需要同步实现 `/model ` 的 set 逻辑。本设计预留此路径但标记为 Phase 2 可选项,优先保证"查看当前模型"的 read-only 路径。 + +### 6.2 `/approval-mode` + +**当前状态**: + +| 输入 | 当前行为 | +| -------------------------- | ----------------------------------------------------------------------------------- | +| `/approval-mode`(无参数) | → `{ type: 'dialog', dialog: 'approval-mode' }`(non-interactive 下变 unsupported) | +| `/approval-mode ` | → `MessageActionReturn` ✅ | +| `/approval-mode ` | → `MessageActionReturn`(error)✅ | + +**变更**: + +1. 将 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 在无参数路径(`!args.trim()`)插入 non-interactive 分支: + +```typescript +if (!args.trim()) { + if (context.executionMode !== 'interactive') { + const currentMode = config?.getApprovalMode() ?? 'unknown'; + return { + type: 'message', + messageType: 'info', + content: `Current approval mode: ${currentMode}\nAvailable modes: ${APPROVAL_MODES.join(', ')}\nUse "/approval-mode " to change.`, + }; + } + return { type: 'dialog', dialog: 'approval-mode' }; +} +``` + +--- + +## 7. B 类:需要完整 non-interactive 分支 + +这五个命令的 action 在 interactive 模式下通过 `context.ui.addItem()` 渲染 React 组件或调用 `context.ui.clear()`,返回值为 `void`。在 non-interactive 中,这些调用均为 no-op,导致 `handleSlashCommand` 将无返回值处理为 `"Command executed successfully."`,无实际内容输出。 + +**实现原则**:在 action **顶部**检查 `executionMode`,非 interactive 时 **提前 return** 包含实际内容的 `message`,interactive 路径代码完全不触碰。 + +### 7.1 `/about`(altName: `status`) + +**数据来源**:`getExtendedSystemInfo(context)` 返回 `ExtendedSystemInfo`,包含:`cliVersion`、`osPlatform`、`osArch`、`osRelease`、`nodeVersion`、`modelVersion`、`selectedAuthType`、`ideClient`、`sessionId`、`memoryUsage`、`baseUrl`、`apiKeyEnvKey`、`gitCommit`、`fastModel`。所有字段在 non-interactive 中均可获取(context.services.config 和 settings 均已注入)。 + +**变更**: + +1. 将 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 在 `getExtendedSystemInfo` 调用后,interactive 路径之前插入模式分支: + +```typescript +action: async (context) => { + const systemInfo = await getExtendedSystemInfo(context); + + if (context.executionMode !== 'interactive') { + const lines = [ + `Qwen Code v${systemInfo.cliVersion}`, + `Model: ${systemInfo.modelVersion}`, + `Fast Model: ${systemInfo.fastModel ?? 'not set'}`, + `Auth: ${systemInfo.selectedAuthType}`, + `Platform: ${systemInfo.osPlatform} ${systemInfo.osArch} (${systemInfo.osRelease})`, + `Node.js: ${systemInfo.nodeVersion}`, + `Session: ${systemInfo.sessionId}`, + ...(systemInfo.gitCommit ? [`Git commit: ${systemInfo.gitCommit}`] : []), + ...(systemInfo.ideClient ? [`IDE: ${systemInfo.ideClient}`] : []), + ]; + return { + type: 'message', + messageType: 'info', + content: lines.join('\n'), + }; + } + + // interactive 路径:原有 addItem 逻辑不变 + const aboutItem: Omit = { type: MessageType.ABOUT, systemInfo }; + context.ui.addItem(aboutItem, Date.now()); +}, +``` + +### 7.2 `/stats`(及子命令 `model`、`tools`) + +**数据来源**:`context.session.stats`(`SessionStatsState`)包含 `sessionStartTime`、`metrics`(`SessionMetrics`:`models`、`tools`、`files`)、`promptCount`。在 non-interactive 中,`sessionStartTime` 为当前调用时刻,`metrics` 来自 `uiTelemetryService.getMetrics()`(本次调用的累积值,通常为零),`promptCount` 为 1。 + +**变更**: + +1. 将父命令 `stats` 及子命令 `model`、`tools` 的 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 父命令和每个子命令的 action 均插入模式分支,提前返回文本格式统计: + +```typescript +// /stats 主命令 +action: (context) => { + if (context.executionMode !== 'interactive') { + const now = new Date(); + const { sessionStartTime, promptCount, metrics } = context.session.stats; + if (!sessionStartTime) { + return { type: 'message', messageType: 'error', content: 'Session start time unavailable.' }; + } + const wallDuration = now.getTime() - sessionStartTime.getTime(); + + // 汇总所有 model 的 token 数 + let totalPromptTokens = 0, totalCandidateTokens = 0, totalRequests = 0; + for (const modelMetrics of Object.values(metrics.models)) { + totalPromptTokens += modelMetrics.tokens.prompt; + totalCandidateTokens += modelMetrics.tokens.candidates; + totalRequests += modelMetrics.api.totalRequests; + } + + const lines = [ + `Session duration: ${formatDuration(wallDuration)}`, + `Prompts: ${promptCount}`, + `API requests: ${totalRequests}`, + `Tokens — prompt: ${totalPromptTokens}, output: ${totalCandidateTokens}`, + `Tool calls: ${metrics.tools.totalCalls} (${metrics.tools.totalSuccess} ok, ${metrics.tools.totalFail} fail)`, + `Files: +${metrics.files.totalLinesAdded} / -${metrics.files.totalLinesRemoved} lines`, + ]; + return { type: 'message', messageType: 'info', content: lines.join('\n') }; + } + + // interactive 路径:原有 addItem 逻辑不变 + const statsItem: HistoryItemStats = { type: MessageType.STATS, duration: formatDuration(wallDuration) }; + context.ui.addItem(statsItem, Date.now()); +}, +``` + +子命令 `model` 和 `tools` 也各自插入模式分支,返回对应维度的文本统计(model 维度按 model name 列出 token 用量;tools 维度列出各 tool 调用次数)。 + +**说明**:在 non-interactive 单次调用中,metrics 通常为零(新 session),但结构完整,不影响格式。ACP Session 中可能有累积值,有实际意义。 + +### 7.3 `/insight` + +**当前状态**:action 返回 `void`,通过 `addItem` 展示进度和结果,最后调用 `open(outputPath)` 打开浏览器。核心逻辑是 `insightGenerator.generateStaticInsight()` 生成 HTML 文件。 + +**变更**: + +1. 将 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 按 `executionMode` 三路分叉: + - `non_interactive`:同步生成,忽略进度回调,不开浏览器,直接返回 `message`(文件路径) + - `acp`:异步启动生成,通过 `stream_messages` 将进度(`encodeInsightProgressMessage`)和完成(`encodeInsightReadyMessage`)推送给 IDE + - `interactive`:原有 `addItem` + `setPendingItem` + `open()` 逻辑不变 + +```typescript +// non_interactive 路径 +if (context.executionMode === 'non_interactive') { + const outputPath = await insightGenerator.generateStaticInsight( + projectsDir, + () => {}, // no-op progress + ); + return { + type: 'message', + messageType: 'info', + content: t('Insight report generated at: {{path}}', { path: outputPath }), + }; +} + +// acp 路径:stream_messages +if (context.executionMode === 'acp') { + // ... 构造 streamMessages async generator,yield encodeInsightProgressMessage / encodeInsightReadyMessage ... + return { type: 'stream_messages', messages: streamMessages() }; +} + +// interactive 路径:原有实现不变 +``` + +**设计理由**:`non_interactive` 模式(CLI 管道)不支持 `stream_messages`,只能返回单条 `message`;ACP 模式(IDE 插件)能消费 `stream_messages` 并实时展示进度,因此为其保留 streaming 路径。 + +**ACP 消息格式**:`encodeInsightProgressMessage(stage, progress, detail?)` 产生 IDE 可解析的进度条消息;`encodeInsightReadyMessage(outputPath)` 通知 IDE 文件已就绪,由 IDE 决定如何展示链接。 + +### 7.4 `/docs` + +**当前状态**:action 返回 `void`,通过 `addItem` 显示消息并调用 `open(docsUrl)` 打开浏览器。有一个 `SANDBOX` 环境变量分支(沙盒下只 addItem,不开浏览器)。 + +**变更**: + +1. 将 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 修改 action 返回类型为 `Promise`。 +3. 在 action 开头插入 non-interactive 分支: + +```typescript +action: async (context) => { + const langPath = getCurrentLanguage()?.startsWith('zh') ? 'zh' : 'en'; + const docsUrl = `https://qwenlm.github.io/qwen-code-docs/${langPath}`; + + if (context.executionMode !== 'interactive') { + // 非交互/ACP:直接返回 URL,不打开浏览器,不调用 addItem + return { + type: 'message', + messageType: 'info', + content: `Qwen Code documentation: ${docsUrl}`, + }; + } + + // interactive 路径:原有 SANDBOX 判断 + addItem + open() 不变 + if (process.env['SANDBOX'] && ...) { + context.ui.addItem(...); + } else { + context.ui.addItem(...); + await open(docsUrl); + } +}, +``` + +### 7.5 `/clear`(altNames: `reset`、`new`) + +**当前状态**:action 执行以下操作并返回 `void`: + +1. `config.getHookSystem()?.fireSessionEndEvent()` — 触发 hook(有副作用) +2. `config.startNewSession()` — 开始新 session ID(有副作用) +3. `uiTelemetryService.reset()` — 重置 telemetry 计数器(有副作用) +4. `skillTool.clearLoadedSkills()` — 清除 skill 缓存(有副作用) +5. `context.ui.clear()` — 清空终端 UI(**UI 副作用,non-interactive 下为 no-op**) +6. `geminiClient.resetChat()` — 重置 chat 历史(有副作用) +7. `config.getHookSystem()?.fireSessionStartEvent()` — 触发 hook(有副作用) + +**non-interactive/ACP 语义分析**: + +- `ui.clear()` 在 non-interactive 中已是 no-op,不需要处理 +- `geminiClient.resetChat()`:在 ACP Session 中是有意义的副作用(清空 chat 历史),应保留;在 non-interactive 单次调用中,每次调用都是全新 session,`resetChat` 语义重复但无害 +- `config.startNewSession()`:在 ACP 中有意义(开始新的 session ID);在 non-interactive 单次调用中同样语义重复但无害 +- `fireSessionEndEvent` / `fireSessionStartEvent`:在 ACP 中有意义(触发 hook) + +**决策**:non-interactive/ACP 路径保留所有有意义的副作用(resetChat、startNewSession、hook events),仅跳过 `ui.clear()`(已是 no-op)并返回上下文边界标记 message。 + +**变更**: + +1. 将 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 修改 action 返回类型为 `Promise`。 +3. 在 action 内,`context.ui.clear()` 调用后(或替代它)根据模式分支: + +```typescript +action: async (context, _args) => { + const { config } = context.services; + + if (config) { + config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Clear).catch(...); + + const newSessionId = config.startNewSession(); + uiTelemetryService.reset(); + + const skillTool = config.getToolRegistry()?.getAllTools().find(...); + if (skillTool instanceof SkillTool) skillTool.clearLoadedSkills(); + + if (newSessionId && context.session.startNewSession) { + context.session.startNewSession(newSessionId); + } + + // ui.clear() 在非交互下已是 no-op,但依然调用(不需要条件分支) + context.ui.clear(); + + const geminiClient = config.getGeminiClient(); + if (geminiClient) { + await geminiClient.resetChat(); + } + + config.getHookSystem()?.fireSessionStartEvent(...).catch(...); + } else { + context.ui.clear(); + } + + // 根据模式决定返回值 + if (context.executionMode !== 'interactive') { + return { + type: 'message', + messageType: 'info', + content: 'Context cleared. Previous messages are no longer in context.', + }; + } + // interactive 路径:void(不返回,React UI 由 ui.clear() 驱动更新) +}, +``` + +**ACP 语义**:IDE 收到上下文边界标记后,可将其作为 session 分隔符展示(如"新会话开始"提示),并清空本地 chat 历史缓存。 + +--- + +## 8. `handleCommandResult` 变更 + +**结论:无需修改。** + +Phase 2 所有命令变更后,non-interactive/ACP 路径的返回类型均为 `message` 或 `submit_prompt`,均已在 `handleCommandResult` 的 switch 中正确处理。 + +--- + +## 9. `createNonInteractiveUI()` 变更 + +**结论:无需修改。** + +当前 no-op 实现已足够。`addItem`、`clear`、`setPendingItem` 等 no-op 在 B 类命令的 non-interactive 路径中不会被调用(因为提前 return);interactive 路径中不受影响。 + +--- + +## 10. Phase 2.2:prompt command 模型调用打通 + +Phase 1 中 `CommandService.getModelInvocableCommands()` 已实现,`BundledSkillLoader`、`FileCommandLoader`(用户/项目命令)、`McpPromptLoader` 已设置 `modelInvocable: true`。 + +Phase 2.2 的工作是将 `SkillTool` 从只消费 `SkillManager.listSkills()` 改为同时消费 `CommandService.getModelInvocableCommands()`,统一模型可调用命令的入口。 + +**变更文件**:`packages/core/src/tools/SkillTool.ts`(或对应路径) + +**具体变更**: + +1. `SkillTool` 在初始化时接收 `CommandService`(或其 `getModelInvocableCommands()` 的结果)作为依赖注入 +2. 在构建 tool description 时,合并 `listSkills()` 和 `getModelInvocableCommands()` 的结果 +3. 确保 built-in commands(`modelInvocable: false`)不出现在 tool description 中 + +> **注**:`SkillTool` 的具体实现依赖 `packages/core` 内部架构,详细设计在本文档中仅描述接口变更,实现细节需结合 core 包的现有结构确定。 + +--- + +## 11. Phase 2.3:mid-input slash command 检测(基础版) + +在 `InputPrompt` 组件中检测光标附近的 slash token(不限于行首),触发补全菜单。 + +**检测规则**: + +- 当光标前存在以 `/` 开头、不含空格的 token 时,触发命令补全 +- 补全候选来自 `getCommandsForMode('interactive')` 的可见命令列表 +- 补全菜单展示命令名 + description(不含 argumentHint 等,Phase 3 补充) + +> 本功能为 UI 层变更,属于 Phase 2.3 独立子任务,不影响其他 Phase 2.1/2.2 的实施。 + +--- + +## 12. 文件变更总览 + +### 12.1 命令文件变更(Phase 2.1) + +| 文件 | 变更类型 | 具体内容 | +| ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `exportCommand.ts` | A 类 | 父命令 + 4 个子命令:`supportedModes` → all modes | +| `planCommand.ts` | 仅交互 | 设计决策:保持 `supportedModes: ['interactive']`,未变更 | +| `statuslineCommand.ts` | 仅交互 | 设计决策:保持 `supportedModes: ['interactive']`,未变更 | +| `languageCommand.ts` | A+ 类 | 父命令 + `ui`/`output` 子命令 + 动态 language 子命令:`supportedModes` → all modes | +| `copyCommand.ts` | 仅交互 | 设计决策:保持 `supportedModes: ['interactive']`,未变更 | +| `restoreCommand.ts` | 仅交互 | 设计决策:保持 `supportedModes: ['interactive']`,未变更 | +| `modelCommand.ts` | A' 类 | `supportedModes` → all modes + 无参数/无 fast model 路径新增非交互分支 | +| `approvalModeCommand.ts` | A' 类 | `supportedModes` → all modes + 无参数路径新增非交互分支 | +| `aboutCommand.ts` | B 类 | `supportedModes` → all modes + 非交互路径返回 `message`(版本/模型/环境摘要) | +| `statsCommand.ts` | B 类 | `supportedModes` → all modes + 非交互路径返回 `message`(stats 文本);子命令同步处理 | +| `insightCommand.ts` | B 类 | `supportedModes` → all modes + `non_interactive` 路径同步生成返回 `message`(文件路径);`acp` 路径返回 `stream_messages` 带进度推送 | +| `docsCommand.ts` | B 类 | `supportedModes` → all modes + 非交互路径返回 `message`(文档 URL),不打开浏览器 | +| `clearCommand.ts` | B 类 | `supportedModes` → all modes + action 末尾根据模式返回 `message` 或 `void` | + +### 12.2 其他文件变更 + +| 文件 | 变更内容 | +| --------------------------------------------------- | ----------------------------------------------------------------- | +| `packages/core/src/tools/SkillTool.ts` | Phase 2.2:接入 `getModelInvocableCommands()`(详细设计另行确定) | +| `packages/cli/src/ui/InputPrompt.tsx`(或同等组件) | Phase 2.3:mid-input slash 检测逻辑 | + +### 12.3 不变的文件 + +- `packages/cli/src/nonInteractiveCliCommands.ts`(`handleCommandResult`、`handleSlashCommand` 无需修改) +- `packages/cli/src/ui/noninteractive/nonInteractiveUi.ts`(stub UI 无需修改) +- `packages/cli/src/services/commandUtils.ts`(`filterCommandsForMode`、`getEffectiveSupportedModes` 无需修改) +- `packages/cli/src/services/CommandService.ts`(`getCommandsForMode`、`getModelInvocableCommands` 已在 Phase 1 实现) + +--- + +## 13. 测试策略 + +### 13.1 命令单元测试 + +为每个变更的命令在同目录下新增或更新测试文件(`*.test.ts`),覆盖以下 case: + +**A/A+ 类命令**(`export`、`language`): + +- `supportedModes` 正确包含 `non_interactive` 和 `acp` +- 在 `executionMode: 'non_interactive'` 下,action 返回 `MessageActionReturn` 或 `SubmitPromptActionReturn`,不调用 `ui.addItem` 或 `ui.clear` +- Interactive 路径行为与重构前完全一致(快照测试) + +**仅交互命令**(`plan`、`statusline`、`copy`、`restore`): + +- `supportedModes` 为 `['interactive']`,这是设计决策 +- 验证 non-interactive 下执行时正确返回 `unsupported` + +**A' 类命令**(`model`、`approval-mode`): + +- 无参数 + `executionMode: 'non_interactive'` → 返回当前状态 `message`,不返回 `dialog` +- 有参数 + `executionMode: 'non_interactive'` → 原有 `message` 逻辑正常执行 +- Interactive 路径:无参数 → `dialog`,有参数 → `message`(不变) + +**B 类命令**(`about`、`stats`、`insight`、`docs`、`clear`): + +- `executionMode: 'non_interactive'` 下,action 返回 `MessageActionReturn`,不调用任何 `ui.*` 方法 +- 返回的 `content` 字符串包含预期的关键字段(版本号、模型名、URL 等) +- Interactive 路径:`ui.addItem` 被调用,`action` 返回 `void`(不变) + +**`clear` 的特殊 case**: + +- `executionMode: 'non_interactive'` 下,`geminiClient.resetChat()` 仍被调用(副作用保留) +- 返回上下文边界 `message`,内容为 `'Context cleared. Previous messages are no longer in context.'` + +### 13.2 集成测试(`handleSlashCommand`) + +在 `nonInteractiveCli.test.ts` 或新建的集成测试文件中: + +- `handleSlashCommand('/about', ...)` 在 non-interactive 模式下返回 `{ type: 'message', content: 包含版本号 }` +- `handleSlashCommand('/stats', ...)` 在 non-interactive 模式下返回 `{ type: 'message', content: 包含 'Session duration' }` +- `handleSlashCommand('/docs', ...)` 在 non-interactive 模式下返回 `{ type: 'message', content: 包含 'qwenlm.github.io' }` +- `handleSlashCommand('/clear', ...)` 在 non-interactive 模式下返回 `{ type: 'message', content: 'Context cleared.' }` +- `handleSlashCommand('/plan', ...)` 在 non-interactive 模式下返回 `unsupported`(仅交互命令) +- 现有 non-interactive 命令(`btw`、`bug` 等)行为无退化 + +### 13.3 `commandUtils` 测试 + +`commandUtils.test.ts` 中新增(或已有的测试继续覆盖): + +- 扩展后的命令(`export`、`language` 等)均能通过 `filterCommandsForMode(commands, 'non_interactive')` 和 `filterCommandsForMode(commands, 'acp')` 的过滤 +- 仅交互命令(`plan`、`statusline`、`copy`、`restore`)在 `filterCommandsForMode(commands, 'non_interactive')` 下被正确过滤掉 + +--- + +## 14. 行为影响分析 + +| 场景 | Phase 2 前行为 | Phase 2 后行为 | 性质 | +| -------------------------------------------- | --------------------------------------------------------- | ---------------------------------- | ------------------ | +| non-interactive 下执行 `/export md` | ❌ unsupported(被过滤) | ✅ 返回文件路径 message | 能力扩展 | +| non-interactive 下执行 `/plan ` | ❌ unsupported | ❌ unsupported(设计决策:仅交互) | 不变 | +| non-interactive 下执行 `/statusline` | ❌ unsupported | ❌ unsupported(设计决策:仅交互) | 不变 | +| non-interactive 下执行 `/language ui zh-CN` | ❌ unsupported | ✅ 设置语言,返回确认 message | 能力扩展 | +| non-interactive 下执行 `/copy` | ❌ unsupported | ❌ unsupported(设计决策:仅交互) | 不变 | +| non-interactive 下执行 `/restore`(无参数) | ❌ unsupported | ❌ unsupported(设计决策:仅交互) | 不变 | +| non-interactive 下执行 `/restore ` | ❌ unsupported | ❌ unsupported(设计决策:仅交互) | 不变 | +| non-interactive 下执行 `/model` | ❌ unsupported(dialog) | ✅ 返回当前模型名称 | 能力扩展 | +| non-interactive 下执行 `/model ` | ❌ unsupported | 🔄 Phase 2 可选:实现切换逻辑 | 能力扩展(可选) | +| non-interactive 下执行 `/approval-mode` | ❌ unsupported(dialog) | ✅ 返回当前审批模式 | 能力扩展 | +| non-interactive 下执行 `/approval-mode yolo` | ❌ unsupported | ✅ 设置模式,返回确认 | 能力扩展 | +| non-interactive 下执行 `/about` | ❌ 返回 "Command executed successfully."(addItem no-op) | ✅ 返回版本/模型/环境摘要 | Bug fix + 能力扩展 | +| non-interactive 下执行 `/stats` | ❌ 返回 "Command executed successfully." | ✅ 返回 session 统计文本 | Bug fix + 能力扩展 | +| non-interactive 下执行 `/insight` | ❌ 返回 "Command executed successfully."(生成但无输出) | ✅ 生成并返回文件路径 | Bug fix + 能力扩展 | +| non-interactive 下执行 `/docs` | ❌ 返回 "Command executed successfully." | ✅ 返回文档 URL | Bug fix + 能力扩展 | +| non-interactive 下执行 `/clear` | ❌ 返回 "Command executed successfully." | ✅ 返回上下文边界 message | Bug fix + 能力扩展 | +| interactive 下执行任意以上命令 | ✅ 原有行为 | ✅ 原有行为(零退化) | 不变 | + +--- + +## 15. 实施顺序 + +建议按以下顺序实施,每组可独立 commit 和 review: + +**Batch 1**(~30min):A 类 — 只改 `supportedModes` + +修改 `exportCommand.ts`(及其子命令),验证测试通过。 + +**Batch 2**(~45min):A+ 类 — 少量分支 + +修改 `languageCommand.ts`,为有副作用的路径添加非交互分支,更新对应测试。(`copyCommand.ts` 和 `restoreCommand.ts` 经讨论保持仅交互。) + +**Batch 3**(~45min):A' 类 — dialog 路径 + +修改 `modelCommand.ts`、`approvalModeCommand.ts`,为无参数路径添加非交互分支,更新对应测试。 + +**Batch 4**(~1.5h):B 类 — 完整分支 + +修改 `aboutCommand.ts`、`statsCommand.ts`(含子命令)、`docsCommand.ts`。 + +**Batch 5**(~1h):B 类特殊 — `insightCommand.ts`、`clearCommand.ts` + +这两个命令副作用较多,单独一个 commit,更新对应测试和集成测试。 + +**Batch 6**(~2h):Phase 2.2 — prompt command 模型调用打通 + +修改 `SkillTool`,接入 `getModelInvocableCommands()`,更新 SkillTool 测试。 + +**Batch 7**(~2h):Phase 2.3 — mid-input slash 检测 + +修改 `InputPrompt` 组件,新增补全触发逻辑和 UI 测试。 + +**Batch 8**(~30min):全量测试 + 类型检查 + +运行 `npm run typecheck`、`cd packages/cli && npx vitest run`,修复剩余问题。 + +--- + +## 16. 验收 Checklist + +**Phase 2.1 命令扩展** + +- [ ] A 类:`/export`(及子命令)、`/plan`、`/statusline` 在 non-interactive 和 acp 模式下可正常执行并返回有意义输出 +- [ ] A+ 类:`/language`(及子命令)在 non-interactive 下正常执行,设置持久化 +- [ ] A+ 类:`/copy` 在 non-interactive/acp 下返回最后 AI 输出文本(不操作剪贴板) +- [ ] A+ 类:`/restore` 无参数时在 non-interactive 下返回 checkpoint 列表;有参数时恢复状态并返回确认 message(不返回 `type: 'tool'`) +- [ ] A' 类:`/model` 无参数时在 non-interactive/acp 下返回当前模型名(不触发 dialog);`/model --fast ` 正常设置 +- [ ] A' 类:`/approval-mode` 无参数时在 non-interactive/acp 下返回当前模式(不触发 dialog);有参数时正常设置 +- [ ] B 类:`/about` 在 non-interactive/acp 下返回包含版本号、模型名的纯文本摘要 +- [ ] B 类:`/stats`(含子命令)在 non-interactive/acp 下返回纯文本统计数据 +- [ ] B 类:`/insight` 在 non-interactive/acp 下生成 insight 文件并返回文件路径(不打开浏览器) +- [ ] B 类:`/docs` 在 non-interactive/acp 下返回文档 URL(不打开浏览器) +- [ ] B 类:`/clear` 在 non-interactive/acp 下返回上下文边界标记 message,`geminiClient.resetChat()` 正常执行 +- [ ] 所有 13 个命令在 interactive 模式下行为与重构前完全一致(无退化) +- [ ] TypeScript 编译无错误(`npm run typecheck`) +- [ ] `npm run lint` 无新增错误 +- [ ] 所有现有测试通过(`cd packages/cli && npx vitest run`) + +**Phase 2.2 模型调用** + +- [ ] 模型在对话中可以通过 `SkillTool` 调用 bundled skill、file command(用户/项目)、MCP prompt +- [ ] 模型不可以调用 built-in commands +- [ ] `SkillTool` 的 tool description 包含所有 `modelInvocable: true` 命令的名称和 description + +**Phase 2.3 mid-input slash** + +- [ ] 在输入框正文中输入 `/` 后触发命令补全菜单(不限行首) +- [ ] 补全菜单展示命令名 + description +- [ ] 补全选中后正确填充到输入框 diff --git a/docs/design/slash-command/roadmap.md b/docs/design/slash-command/roadmap.md index b62e79ff1..106778327 100644 --- a/docs/design/slash-command/roadmap.md +++ b/docs/design/slash-command/roadmap.md @@ -107,52 +107,91 @@ #### 2.1 扩展 non-interactive / acp 可用命令集 -将以下命令的 `supportedModes` 扩展到包含 `non_interactive` 和 `acp`,并确保其 action 实现可在无 UI 环境运行: +**ACP 语义设计原则** -**直接可扩展**(action 已无 UI 依赖): +将命令扩展到 ACP/non-interactive 模式前,需遵循以下设计原则: -- `/export`:文件 I/O,返回 `message` -- `/memory`:文件 I/O,返回 `message` -- `/plan`:返回 `submit_prompt` -- `/tools`:改为返回 `message`(文本列表,替换 UI 渲染) -- `/stats`:改为返回 `message`(文本格式,替换 UI 渲染) +1. **接收方不同**:ACP 模式下消息的接收方是 IDE(Zed/VS Code 插件),而非终端用户。消息内容以纯文本或 Markdown 格式为宜,不应包含 terminal 专用的 ANSI 样式。 +2. **实现策略是增加模式分支,而非替换**:正确做法是在命令的 `action` 内部新增模式判断——interactive 路径保持现有 UI 渲染逻辑不变,non_interactive/acp 路径返回适合机器消费的 `message` 或 `submit_prompt`。两条路径共存于同一个 `action` 函数中。 +3. **有状态操作需说明语义**:在单次非交互调用中(如 CLI `-p` 参数),`/model set`、`/language set` 等有状态命令的变更仅在本次 session 内有效,应在命令响应文本中注明。 +4. **只读 vs 有副作用**:只读命令(如 `/about`、`/stats`)直接返回当前状态文本;有副作用命令(如 `/model set`、`/language set`)需在响应中确认操作结果。 +5. **避免环境相关副作用**:打开浏览器(`/docs`、`/insight`)、操作剪贴板(`/copy`)等依赖图形环境的操作,在 non_interactive/acp 路径下应跳过,改为在响应文本中返回相关 URL 或内容本身。 -**需要 local 子命令拆分**(当前只有 `local-jsx` 壳): +**待扩展命令总览** -| 命令 | 新增的 local 子命令 | -| -------------- | ----------------------------------------------------------------------------- | -| `/model` | `show`(当前模型)、`list`(可选列表)、`set `(切换) | -| `/permissions` | `show`(当前权限模式)、`set `(设置) | -| `/mcp` | `list`(MCP 服务列表)、`show `(服务详情)、`status`(所有服务状态) | -| `/memory` | 已有 `show`/`add`/`refresh`(确认 non-interactive 下可用) | +> 注:`btw`、`bug`、`compress`、`context`、`init`、`summary` 已在 Phase 1 中扩展到全模式,不在本阶段列表中。 -> **注意**:上述 UI 壳命令不会被删除,`/model` 不带子命令时仍然打开 dialog(interactive 模式)。新增子命令是 **在现有命令上追加**,不是替换。 +以下 13 个命令将在 Phase 2 中扩展到 `non_interactive` 和 `acp` 模式: + +**A 类:action 已返回 `message` 或 `submit_prompt`,只需扩展 `supportedModes` 并设计 ACP 消息内容** + +| 命令 | 返回类型 | ACP/non-interactive 处理要点 | +| ------------- | --------------- | -------------------------------------------------- | +| `/copy` | `message` | ACP 下无剪贴板,改为在响应文本中返回内容本身或提示 | +| `/export` | `message` | 返回导出文件的完整路径 | +| `/plan` | `submit_prompt` | 无需改动,直接扩展模式 | +| `/restore` | `message` | 返回恢复操作的结果描述 | +| `/language` | `message` | 返回当前语言设置或变更确认文本 | +| `/statusline` | `submit_prompt` | 无需改动,直接扩展模式 | + +**A' 类:有参数时正常执行,无参数时触发 dialog(需增加无参数路径的 non-interactive 处理)** + +| 命令 | 无参数 interactive 行为 | 无参数 non_interactive/acp 行为 | +| ---------------- | ----------------------- | ------------------------------- | +| `/model` | 打开模型选择 dialog | 返回当前模型名称及说明文本 | +| `/approval-mode` | 打开审批模式 dialog | 返回当前审批模式及说明文本 | + +**B 类:action 内部使用 `context.ui.addItem()` 渲染 React 组件,需增加模式分支返回纯文本** + +| 命令 | interactive 行为 | non_interactive/acp 返回内容 | +| ---------- | ------------------------- | ----------------------------------------------------------------------------------- | +| `/about` | 渲染版本/配置 React 组件 | 版本号、当前模型、关键配置的纯文本摘要 | +| `/stats` | 渲染 token/费用统计组件 | session 统计数据的纯文本格式 | +| `/insight` | 渲染分析组件 + 打开浏览器 | `non_interactive` 同步生成返回文件路径;`acp` 通过 `stream_messages` 推送进度和结果 | +| `/docs` | 渲染文档入口 + 打开浏览器 | 返回文档 URL,不打开浏览器 | + +**C 类:特殊处理** + +| 命令 | interactive 行为 | non_interactive/acp 行为 | +| -------- | -------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `/clear` | 调用 `context.ui.clear()` 清空终端显示 | 返回上下文边界标记 message,内容为 `"Context cleared. Previous messages are no longer in context."` | #### 2.2 prompt command 模型调用打通 - 在 `CommandService`(或 `CommandRegistry`)中实现 `getModelInvocableCommands()`,返回所有 `modelInvocable: true` 的命令 -- 将 `BundledSkillLoader`、`FileCommandLoader`(用户/项目命令)、`McpPromptLoader` 加载的命令标记为 `modelInvocable: true` +- 将 `BundledSkillLoader`、`FileCommandLoader`(用户/项目命令)加载的命令标记为 `modelInvocable: true` +- **MCP prompt 不标记为 `modelInvocable`**:MCP prompt 通过独立的 MCP tool call 机制由模型调用,无需经过 `SkillTool` 中转 - 改造 `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 做) +- 检测到 slash token 后通过 inline ghost text 提示最佳匹配命令名(Tab 接受) +- **不**包含 dropdown 补全菜单、argument hints、source badge 等(Phase 3 做) +- ghost text 候选集仅限 `modelInvocable: true` 的命令(skill / file command) ### 验收标准 -- [ ] `/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 +**2.1 命令扩展** + +- [ ] A 类:`/copy`、`/export`、`/plan`、`/restore`、`/language`、`/statusline` 在 non-interactive 和 acp 模式下可正常执行并返回有意义的文本输出 +- [ ] A' 类:`/model`、`/approval-mode` 无参数时在 non-interactive/acp 下返回当前状态文本(不触发 dialog);有参数时执行变更并返回确认文本 +- [ ] B 类:`/about`、`/stats`、`/docs` 在 non-interactive/acp 下返回纯文本,`/docs` 不打开浏览器;`/insight` 在 `non_interactive` 下同步生成并返回文件路径 message,在 `acp` 下通过 `stream_messages` 推送进度 +- [ ] C 类:`/clear` 在 non-interactive/acp 下返回上下文边界标记 message,不调用 `context.ui.clear()` +- [ ] 所有扩展命令在 interactive 模式下行为与重构前完全一致(无退化) + +**2.2 模型调用** + +- [ ] 模型在对话中可以通过 `SkillTool` 调用 bundled skill、file command(用户/项目) +- [ ] MCP prompt 不经过 `SkillTool`,通过 MCP tool call 机制由模型原生调用 - [ ] 模型不可以调用 built-in commands(`userInvocable: true`,`modelInvocable: false`) -- [ ] mid-input slash:在正文中输入 `/` 后触发命令补全菜单 - [ ] `SkillTool` 的 description 包含所有 `modelInvocable` 命令的描述 +**2.3 mid-input slash** + +- [ ] mid-input slash:在正文中输入 `/` 后通过 inline ghost text 提示最佳匹配命令(Tab 接受) + --- ## Phase 3:体验对齐(补全增强 + Claude Code 命令补齐) diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index f6bd50ae6..e40378bf9 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -618,11 +618,8 @@ class QwenAgent implements Agent { await geminiClient.initialize(); } - const chat = geminiClient.getChat(); - const session = new Session( sessionId, - chat, config, this.connection, this.settings, diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.ts index 2c3f5d1d2..20af40b6b 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.ts @@ -7,6 +7,7 @@ import type { ChatRecord, AgentResultDisplay, + SlashCommandRecordPayload, NotificationRecordPayload, } from '@qwen-code/qwen-code-core'; import type { @@ -90,8 +91,14 @@ export class HistoryReplayer { await this.replayToolResult(record); break; + case 'system': + if (record.subtype === 'slash_command') { + await this.replaySlashCommandResult(record); + } + // Other system subtypes (compression, telemetry, at_command) are skipped. + break; + default: - // Skip system records (compression, telemetry, slash commands) break; } this.setActiveRecordId(null); @@ -224,6 +231,29 @@ export class HistoryReplayer { } } + /** + * Replays a slash_command system record by re-emitting its output as an + * agent message chunk. This allows Zed to reconstruct the correct turn + * structure (user → agent) on session resume without polluting model context. + */ + private async replaySlashCommandResult(record: ChatRecord): Promise { + const payload = record.systemPayload as + | SlashCommandRecordPayload + | undefined; + if (payload?.phase !== 'result' || !payload.outputHistoryItems?.length) { + return; + } + for (const item of payload.outputHistoryItems) { + const text = typeof item['text'] === 'string' ? item['text'] : ''; + if (text) { + await this.messageEmitter.emitAgentMessage( + text.replace(/\n/g, ' \n'), + record.timestamp, + ); + } + } + } + /** * Extracts tool name from a chat record's function response. */ diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 35315f0e5..ea8a44dd6 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -117,6 +117,9 @@ describe('Session', () => { getDebugMode: vi.fn().mockReturnValue(false), getAuthType: vi.fn().mockImplementation(() => currentAuthType), isCronEnabled: vi.fn().mockReturnValue(false), + getGeminiClient: vi + .fn() + .mockReturnValue({ getChat: vi.fn().mockReturnValue(mockChat) }), } as unknown as Config; mockClient = { @@ -137,7 +140,6 @@ describe('Session', () => { session = new Session( 'test-session-id', - mockChat, mockConfig, mockClient, mockSettings, @@ -308,7 +310,6 @@ describe('Session', () => { core.Storage.setRuntimeBaseDir(runtimeDir); session = new Session( 'test-session-id', - mockChat, mockConfig, mockClient, mockSettings, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 95961091c..9c4d0f999 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -147,7 +147,6 @@ export class Session implements SessionContext { constructor( id: string, - private readonly chat: GeminiChat, readonly config: Config, private readonly client: AgentSideConnection, private readonly settings: LoadedSettings, @@ -294,7 +293,9 @@ export class Session implements SessionContext { // Increment turn counter for each user prompt this.turn += 1; - const chat = this.chat; + // Always fetch the current chat from GeminiClient so that /clear's + // resetChat() (which replaces the chat instance) is reflected here. + const chat = this.config.getGeminiClient()!.getChat(); const promptId = this.config.getSessionId() + '########' + this.turn; // Extract text from all text blocks to construct the full prompt text for logging @@ -591,12 +592,12 @@ export class Session implements SessionContext { // Get response text from the chat history const history = chat.getHistory(); const lastModelMessage = history - .filter((msg) => msg.role === 'model') + .filter((msg: Content) => msg.role === 'model') .pop(); const responseText = lastModelMessage?.parts - ?.filter((p): p is { text: string } => 'text' in p) - .map((p) => p.text) + ?.filter((p: Part): p is { text: string } & Part => 'text' in p) + .map((p: { text: string }) => p.text) .join('') || '[no response text]'; const response = await messageBus.request< @@ -894,14 +895,17 @@ export class Session implements SessionContext { null; const streamStartTime = Date.now(); - const responseStream = await this.chat.sendMessageStream( - this.config.getModel(), - { - message: nextMessage.parts ?? [], - config: { abortSignal: ac.signal }, - }, - promptId, - ); + const responseStream = await this.config + .getGeminiClient()! + .getChat() + .sendMessageStream( + this.config.getModel(), + { + message: nextMessage.parts ?? [], + config: { abortSignal: ac.signal }, + }, + promptId, + ); nextMessage = null; for await (const resp of responseStream) { @@ -1752,44 +1756,59 @@ export class Session implements SessionContext { return normalizePartList(result.content); case 'message': { - await this.client.extNotification('_qwencode/slash_command', { - sessionId: this.sessionId, - command: originalPrompt - .filter((block) => block.type === 'text') - .map((block) => (block.type === 'text' ? block.text : '')) - .join(' '), - messageType: result.messageType, - message: result.content || '', - }); - if (result.messageType === 'error') { // Throw error to stop execution throw new Error(result.content || 'Slash command failed.'); } - // For info messages, return null to indicate command was handled + // Emit the message as an agent message chunk so Zed renders it in the + // chat UI. extNotification only goes to the ACP debug log and is not + // rendered by Zed. + // Replace bare \n with Markdown hard line-breaks (two trailing spaces) + // so Zed's Markdown renderer preserves the line structure. + const rendered = (result.content || '').replace(/\n/g, ' \n'); + await this.messageEmitter.emitAgentMessage(rendered); + // Write a system/slash_command record so history replay on restart can + // re-emit this message. system records are skipped by + // buildApiHistoryFromConversation, so this won't pollute model context. + this.config.getChatRecordingService()?.recordSlashCommand({ + phase: 'result', + rawCommand: originalPrompt + .filter((b) => b.type === 'text') + .map((b) => (b.type === 'text' ? b.text : '')) + .join(' '), + outputHistoryItems: [ + { type: 'assistant', text: result.content || '' }, + ], + }); return null; } case 'stream_messages': { // Command returns multiple messages via async generator (ACP-preferred) - const command = originalPrompt - .filter((block) => block.type === 'text') - .map((block) => (block.type === 'text' ? block.text : '')) - .join(' '); - - // Stream all messages to the client + // Stream all messages to the client as agent message chunks. + const chunks: string[] = []; for await (const msg of result.messages) { - await this.client.extNotification('_qwencode/slash_command', { - sessionId: this.sessionId, - command, - messageType: msg.messageType, - message: msg.content, - }); - - // If we encounter an error message, throw after sending if (msg.messageType === 'error') { throw new Error(msg.content || 'Slash command failed.'); } + await this.messageEmitter.emitAgentMessage( + (msg.content || '').replace(/\n/g, ' \n'), + ); + chunks.push(msg.content || ''); + } + // Write a system/slash_command record for history replay (same reason as + // 'message' case — system records are invisible to model history). + if (chunks.length > 0) { + this.config.getChatRecordingService()?.recordSlashCommand({ + phase: 'result', + rawCommand: originalPrompt + .filter((b) => b.type === 'text') + .map((b) => (b.type === 'text' ? b.text : '')) + .join(' '), + outputHistoryItems: [ + { type: 'assistant', text: chunks.join('\n') }, + ], + }); } // All messages sent successfully, return null to indicate command was handled diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index bc3a8f6f2..d85d11941 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -524,13 +524,6 @@ export async function parseArguments(): Promise { coerce: (tools: string[]) => tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), }) - .option('allowed-tools', { - type: 'array', - string: true, - description: 'Tools to allow, will bypass confirmation', - coerce: (tools: string[]) => - tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), - }) .option('disabled-slash-commands', { type: 'array', string: true, @@ -542,6 +535,13 @@ export async function parseArguments(): Promise { coerce: (names: string[]) => names.flatMap((n) => n.split(',').map((t) => t.trim())), }) + .option('allowed-tools', { + type: 'array', + string: true, + description: 'Tools to allow, will bypass confirmation', + coerce: (tools: string[]) => + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + }) .option('auth-type', { type: 'string', choices: [ diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 61a5164a0..8aa7517a6 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1126,6 +1126,36 @@ const SETTINGS_SCHEMA = { }, }, + slashCommands: { + type: 'object', + label: 'Slash Commands', + category: 'Advanced', + requiresRestart: true, + default: {}, + description: + 'Configuration for slash commands exposed by the CLI. Useful for ' + + 'locking down the command surface in multi-tenant or enterprise ' + + 'deployments.', + showInDialog: false, + properties: { + disabled: { + type: 'array', + label: 'Disabled Slash Commands', + category: 'Advanced', + requiresRestart: true, + default: undefined as string[] | undefined, + description: + 'Slash command names to hide and refuse to execute. Matched ' + + 'case-insensitively against the final command name (for extension ' + + 'commands this is the disambiguated form, e.g. "myext.deploy"). ' + + 'Merged as a union across settings scopes, so workspace settings ' + + 'can add to but not remove entries defined in system/user settings.', + showInDialog: false, + mergeStrategy: MergeStrategy.UNION, + }, + }, + }, + permissions: { type: 'object', label: 'Permissions', @@ -1175,36 +1205,6 @@ const SETTINGS_SCHEMA = { }, }, - slashCommands: { - type: 'object', - label: 'Slash Commands', - category: 'Advanced', - requiresRestart: true, - default: {}, - description: - 'Configuration for slash commands exposed by the CLI. Useful for ' + - 'locking down the command surface in multi-tenant or enterprise ' + - 'deployments.', - showInDialog: false, - properties: { - disabled: { - type: 'array', - label: 'Disabled Slash Commands', - category: 'Advanced', - requiresRestart: true, - default: undefined as string[] | undefined, - description: - 'Slash command names to hide and refuse to execute. Matched ' + - 'case-insensitively against the final command name (for extension ' + - 'commands this is the disambiguated form, e.g. "myext.deploy"). ' + - 'Merged as a union across settings scopes, so workspace settings ' + - 'can add to but not remove entries defined in system/user settings.', - showInDialog: false, - mergeStrategy: MergeStrategy.UNION, - }, - }, - }, - tools: { type: 'object', label: 'Tools', diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 33e4dff96..9f3e339eb 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -152,12 +152,14 @@ describe('runNonInteractive', () => { isInteractive: vi.fn().mockReturnValue(false), isCronEnabled: vi.fn().mockReturnValue(false), getCronScheduler: vi.fn().mockReturnValue(null), + setModelInvocableCommandsProvider: vi.fn(), + setModelInvocableCommandsExecutor: vi.fn(), + getDisabledSlashCommands: vi.fn().mockReturnValue([]), getBackgroundTaskRegistry: vi.fn().mockReturnValue({ setNotificationCallback: vi.fn(), setRegisterCallback: vi.fn(), getRunning: vi.fn().mockReturnValue([]), }), - getDisabledSlashCommands: vi.fn().mockReturnValue([]), } as unknown as Config; mockSettings = { diff --git a/packages/cli/src/nonInteractiveCliCommands.test.ts b/packages/cli/src/nonInteractiveCliCommands.test.ts index 1e83abcad..8e40b633d 100644 --- a/packages/cli/src/nonInteractiveCliCommands.test.ts +++ b/packages/cli/src/nonInteractiveCliCommands.test.ts @@ -43,6 +43,8 @@ describe('handleSlashCommand', () => { getFolderTrustFeature: vi.fn().mockReturnValue(false), getFolderTrust: vi.fn().mockReturnValue(false), getProjectRoot: vi.fn().mockReturnValue('/test/project'), + setModelInvocableCommandsProvider: vi.fn(), + setModelInvocableCommandsExecutor: vi.fn(), getDisabledSlashCommands: vi.fn().mockReturnValue([]), storage: {}, } as unknown as Config; @@ -86,7 +88,7 @@ describe('handleSlashCommand', () => { name: 'help', description: 'Show help', kind: CommandKind.BUILT_IN, - // No commandType → falls back to BUILT_IN → interactive only + // No supportedModes → BUILT_IN fallback → interactive only action: vi.fn(), }; mockGetCommands.mockReturnValue([mockHelpCommand]); @@ -135,7 +137,6 @@ describe('handleSlashCommand', () => { 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', @@ -163,7 +164,6 @@ describe('handleSlashCommand', () => { 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', @@ -276,4 +276,67 @@ describe('handleSlashCommand', () => { expect(result.messageType).toBe('info'); } }); + + describe('disabled slash commands', () => { + const mockDisabledCommand = { + name: 'help', + description: 'Show help', + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'info', + content: 'Help content', + }), + }; + + it('should return unsupported with disabled reason for a disabled command', async () => { + mockGetCommands.mockReturnValue([mockDisabledCommand]); + vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['help']); + + const result = await handleSlashCommand( + '/help', + abortController, + mockConfig, + mockSettings, + ); + + expect(result.type).toBe('unsupported'); + if (result.type === 'unsupported') { + expect(result.reason).toContain('disabled'); + expect(result.originalType).toBe('filtered_command'); + } + }); + + it('should match disabled command names case-insensitively', async () => { + mockGetCommands.mockReturnValue([mockDisabledCommand]); + vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['HELP']); + + const result = await handleSlashCommand( + '/help', + abortController, + mockConfig, + mockSettings, + ); + + expect(result.type).toBe('unsupported'); + if (result.type === 'unsupported') { + expect(result.reason).toContain('disabled'); + } + }); + + it('should still return no_command for genuinely unknown commands even with a denylist', async () => { + mockGetCommands.mockReturnValue([mockDisabledCommand]); + vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['help']); + + const result = await handleSlashCommand( + '/unknowncommand', + abortController, + mockConfig, + mockSettings, + ); + + expect(result.type).toBe('no_command'); + }); + }); }); diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 5a5fc69ac..a3b49a403 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -16,6 +16,7 @@ import { CommandService } from './services/CommandService.js'; import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js'; import { BundledSkillLoader } from './services/BundledSkillLoader.js'; import { FileCommandLoader } from './services/FileCommandLoader.js'; +import { SkillCommandLoader } from './services/SkillCommandLoader.js'; import { type CommandContext, type SlashCommand, @@ -200,15 +201,72 @@ export const handleSlashCommand = async ( const allLoaders = [ new BuiltinCommandLoader(config), new BundledSkillLoader(config), + new SkillCommandLoader(config), new FileCommandLoader(config), ]; + // Build the disabled-command set (case-insensitive). + 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; altNames?: readonly string[] }) => + disabledNameSet.has(cmd.name.toLowerCase()) || + (cmd.altNames ?? []).some((a) => disabledNameSet.has(a.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. const commandService = await CommandService.create( allLoaders, abortController.signal, ); + // Register model-invocable commands provider so SkillTool description stays + // up-to-date in non-interactive / ACP mode. + config.setModelInvocableCommandsProvider(() => + commandService.getModelInvocableCommands().map((cmd) => ({ + name: cmd.name, + description: + typeof cmd.description === 'string' ? cmd.description : cmd.description, + })), + ); + // Register executor so SkillTool can invoke model-invocable commands + // (e.g. MCP prompts) that are not file-based skills. + config.setModelInvocableCommandsExecutor( + async (name: string, args: string = '') => { + const commands = commandService.getModelInvocableCommands(); + const cmd = commands.find((c) => c.name === name); + if (!cmd?.action) return null; + const minimalContext = { + executionMode, + invocation: { + raw: args ? `/${name} ${args}` : `/${name}`, + name, + args, + }, + services: { config, settings, git: undefined, logger: null }, + } as unknown as CommandContext; + const result = await cmd.action(minimalContext, args); + if (!result || result.type !== 'submit_prompt') return null; + const content = result.content; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .map((p) => + typeof p === 'string' ? p : ((p as { text?: string }).text ?? ''), + ) + .join(''); + } + return null; + }, + ); const allCommands = commandService.getCommands(); - const filteredCommands = commandService.getCommandsForMode(executionMode); + const filteredCommands = commandService + .getCommandsForMode(executionMode) + .filter((cmd) => !isDisabled(cmd)); // First, try to parse with filtered commands const { commandToExecute, args } = parseSlashCommand( @@ -224,11 +282,26 @@ export const handleSlashCommand = async ( ); if (knownCommand) { + // Derive the token the user actually typed (e.g. "about" when the + // primary name is "status") to surface a helpful error message. + const typedToken = + rawQuery.trim().substring(1).trim().split(/\s+/)[0] ?? + knownCommand.name; + if (isDisabled(knownCommand)) { + return { + type: 'unsupported', + reason: t( + 'The command "/{{command}}" is disabled by the current configuration.', + { command: typedToken }, + ), + originalType: 'filtered_command', + }; + } // Command exists but is not allowed in this mode return { type: 'unsupported', reason: t('The command "/{{command}}" is not supported in this mode.', { - command: knownCommand.name, + command: typedToken, }), originalType: 'filtered_command', }; @@ -304,10 +377,18 @@ export const getAvailableCommands = async ( const loaders = [ new BuiltinCommandLoader(config), new BundledSkillLoader(config), + new SkillCommandLoader(config), new FileCommandLoader(config), ]; - const commandService = await CommandService.create(loaders, abortSignal); + const disabledSlashCommands = config.getDisabledSlashCommands(); + const commandService = await CommandService.create( + loaders, + abortSignal, + disabledSlashCommands.length > 0 + ? new Set(disabledSlashCommands) + : undefined, + ); return commandService.getCommandsForMode(mode) as SlashCommand[]; } catch (error) { // Handle errors gracefully - log and return empty array diff --git a/packages/cli/src/services/BundledSkillLoader.ts b/packages/cli/src/services/BundledSkillLoader.ts index faa39910d..5ea6682b5 100644 --- a/packages/cli/src/services/BundledSkillLoader.ts +++ b/packages/cli/src/services/BundledSkillLoader.ts @@ -65,8 +65,8 @@ export class BundledSkillLoader implements ICommandLoader { kind: CommandKind.SKILL, source: 'bundled-skill' as const, sourceLabel: 'Skill', - commandType: 'prompt' as const, - modelInvocable: true, + modelInvocable: !skill.disableModelInvocation, + whenToUse: skill.whenToUse, action: async (context, _args): Promise => { // Resolve template variables in skill body let body = skill.body; diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index 122a741ff..545eb22ad 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -310,80 +310,6 @@ describe('CommandService', () => { expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); }); - describe('disabledNames filtering', () => { - it('should omit commands whose names are in the disabled set', async () => { - const loader = new MockCommandLoader([ - mockCommandA, - mockCommandB, - mockCommandC, - ]); - const service = await CommandService.create( - [loader], - new AbortController().signal, - new Set(['command-b']), - ); - const names = service.getCommands().map((cmd) => cmd.name); - expect(names).toEqual(expect.arrayContaining(['command-a', 'command-c'])); - expect(names).not.toContain('command-b'); - }); - - it('should match disabled names case-insensitively', async () => { - const loader = new MockCommandLoader([mockCommandA, mockCommandB]); - const service = await CommandService.create( - [loader], - new AbortController().signal, - new Set(['COMMAND-A']), - ); - const names = service.getCommands().map((cmd) => cmd.name); - expect(names).toEqual(['command-b']); - }); - - it('should ignore empty entries and whitespace in the disabled set', async () => { - const loader = new MockCommandLoader([mockCommandA, mockCommandB]); - const service = await CommandService.create( - [loader], - new AbortController().signal, - new Set(['', ' ', ' command-a ']), - ); - const names = service.getCommands().map((cmd) => cmd.name); - expect(names).toEqual(['command-b']); - }); - - it('should be a no-op when disabledNames is undefined or empty', async () => { - const loader = new MockCommandLoader([mockCommandA, mockCommandB]); - const undefinedResult = await CommandService.create( - [loader], - new AbortController().signal, - ); - expect(undefinedResult.getCommands()).toHaveLength(2); - - const emptyResult = await CommandService.create( - [new MockCommandLoader([mockCommandA, mockCommandB])], - new AbortController().signal, - new Set(), - ); - expect(emptyResult.getCommands()).toHaveLength(2); - }); - - it('should disable extension commands by their renamed (final) name', async () => { - const builtin = createMockCommand('deploy', CommandKind.BUILT_IN); - const extension = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'firebase', - description: '[firebase] Deploy to Firebase', - }; - const loader = new MockCommandLoader([builtin, extension]); - const service = await CommandService.create( - [loader], - new AbortController().signal, - new Set(['firebase.deploy']), - ); - const names = service.getCommands().map((cmd) => cmd.name); - // Built-in /deploy remains; the renamed extension command is gone. - expect(names).toEqual(['deploy']); - }); - }); - it('should handle multiple secondary conflicts with incrementing suffixes', async () => { // User has /deploy, /gcp.deploy, and /gcp.deploy1 const userCommand1 = createMockCommand('deploy', CommandKind.FILE); @@ -419,4 +345,59 @@ describe('CommandService', () => { expect(deployExtension).toBeDefined(); expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); }); + + describe('disabled commands (disabledNames parameter)', () => { + it('should exclude commands whose names are in the disabledNames set', async () => { + const mockLoader = new MockCommandLoader([ + mockCommandA, + mockCommandB, + mockCommandC, + ]); + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + new Set(['command-a']), + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(2); + expect(commands.find((c) => c.name === 'command-a')).toBeUndefined(); + expect(commands.find((c) => c.name === 'command-b')).toBeDefined(); + expect(commands.find((c) => c.name === 'command-c')).toBeDefined(); + }); + + it('should match disabled names case-insensitively', async () => { + const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]); + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + new Set(['COMMAND-A', 'Command-B']), + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(0); + }); + + it('should not filter any commands when disabledNames is empty', async () => { + const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]); + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + new Set(), + ); + + expect(service.getCommands()).toHaveLength(2); + }); + + it('should not filter any commands when disabledNames is undefined', async () => { + const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]); + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + undefined, + ); + + expect(service.getCommands()).toHaveLength(2); + }); + }); }); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index d4bcc6641..8fa67b7b4 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -34,9 +34,8 @@ export class CommandService { * * This factory method orchestrates the entire command loading process. It * runs all provided loaders in parallel, aggregates their results, handles - * name conflicts for extension commands by renaming them, optionally filters - * out disabled commands, and then returns a fully constructed - * `CommandService` instance. + * name conflicts for extension commands by renaming them, and then returns a + * fully constructed `CommandService` instance. * * Conflict resolution: * - Extension commands that conflict with existing commands are renamed to @@ -102,8 +101,12 @@ export class CommandService { if (trimmed) normalizedDisabled.add(trimmed.toLowerCase()); } if (normalizedDisabled.size > 0) { - for (const name of Array.from(commandMap.keys())) { - if (normalizedDisabled.has(name.toLowerCase())) { + for (const [name, cmd] of Array.from(commandMap.entries())) { + const matchesPrimary = normalizedDisabled.has(name.toLowerCase()); + const matchesAlias = (cmd.altNames ?? []).some((a) => + normalizedDisabled.has(a.toLowerCase()), + ); + if (matchesPrimary || matchesAlias) { commandMap.delete(name); } } diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index e9fc82355..ba03c9567 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -354,6 +354,9 @@ export class FileCommandLoader implements ICommandLoader { typeof validDef.frontmatter.description === 'string' ? validDef.frontmatter.description : undefined, + whenToUse: validDef.frontmatter?.when_to_use, + disableModelInvocation: + validDef.frontmatter?.['disable-model-invocation'], }; // Use factory to create command diff --git a/packages/cli/src/services/McpPromptLoader.ts b/packages/cli/src/services/McpPromptLoader.ts index c18e67e88..d195c96f8 100644 --- a/packages/cli/src/services/McpPromptLoader.ts +++ b/packages/cli/src/services/McpPromptLoader.ts @@ -48,8 +48,6 @@ export class McpPromptLoader implements ICommandLoader { 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/SkillCommandLoader.test.ts b/packages/cli/src/services/SkillCommandLoader.test.ts new file mode 100644 index 000000000..00c189123 --- /dev/null +++ b/packages/cli/src/services/SkillCommandLoader.test.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SkillCommandLoader } from './SkillCommandLoader.js'; +import { CommandKind } from '../ui/commands/types.js'; +import type { Config, SkillConfig } from '@qwen-code/qwen-code-core'; + +function makeSkill(overrides: Partial = {}): SkillConfig { + return { + name: 'my-skill', + description: 'My skill description', + level: 'user', + filePath: '/home/user/.qwen/skills/my-skill/SKILL.md', + body: 'Skill body content.', + ...overrides, + }; +} + +describe('SkillCommandLoader', () => { + let mockConfig: Config; + let mockSkillManager: { listSkills: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + mockSkillManager = { + listSkills: vi.fn().mockResolvedValue([]), + }; + mockConfig = { + getSkillManager: vi.fn().mockReturnValue(mockSkillManager), + getBareMode: vi.fn().mockReturnValue(false), + } as unknown as Config; + }); + + const signal = new AbortController().signal; + + it('should return empty array when config is null', async () => { + const loader = new SkillCommandLoader(null); + expect(await loader.loadCommands(signal)).toEqual([]); + }); + + it('should return empty array when SkillManager is not available', async () => { + const config = { + getSkillManager: vi.fn().mockReturnValue(null), + getBareMode: vi.fn().mockReturnValue(false), + } as unknown as Config; + const loader = new SkillCommandLoader(config); + expect(await loader.loadCommands(signal)).toEqual([]); + }); + + it('should return empty array in bare mode', async () => { + (mockConfig.getBareMode as ReturnType).mockReturnValue(true); + const loader = new SkillCommandLoader(mockConfig); + expect(await loader.loadCommands(signal)).toEqual([]); + expect(mockSkillManager.listSkills).not.toHaveBeenCalled(); + }); + + it('should query user, project, and extension levels', async () => { + const loader = new SkillCommandLoader(mockConfig); + await loader.loadCommands(signal); + expect(mockSkillManager.listSkills).toHaveBeenCalledWith({ level: 'user' }); + expect(mockSkillManager.listSkills).toHaveBeenCalledWith({ + level: 'project', + }); + expect(mockSkillManager.listSkills).toHaveBeenCalledWith({ + level: 'extension', + }); + }); + + it('should load user skill as slash command with correct properties', async () => { + const skill = makeSkill({ level: 'user' }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'user' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const cmd = commands[0]; + expect(cmd.name).toBe('my-skill'); + expect(cmd.description).toBe('My skill description'); + expect(cmd.kind).toBe(CommandKind.SKILL); + expect(cmd.source).toBe('skill-dir-command'); + expect(cmd.sourceLabel).toBe('User'); + expect(cmd.modelInvocable).toBe(true); + }); + + it('should load project skill with sourceLabel "Project"', async () => { + const skill = makeSkill({ level: 'project' }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'project' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0].sourceLabel).toBe('Project'); + expect(commands[0].source).toBe('skill-dir-command'); + expect(commands[0].modelInvocable).toBe(true); + }); + + it('should submit skill body as prompt', async () => { + const skill = makeSkill(); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'user' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + const result = await commands[0].action!( + { invocation: { raw: '/my-skill', args: '' } } as never, + '', + ); + + expect(result).toEqual({ + type: 'submit_prompt', + content: [{ text: 'Skill body content.' }], + }); + }); + + it('should append raw invocation when args are provided', async () => { + const skill = makeSkill(); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'user' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + const result = await commands[0].action!( + { invocation: { raw: '/my-skill foo', args: 'foo' } } as never, + 'foo', + ); + + expect(result).toEqual({ + type: 'submit_prompt', + content: [{ text: 'Skill body content.\n\n/my-skill foo' }], + }); + }); + + it('should return empty array when listSkills throws', async () => { + mockSkillManager.listSkills.mockRejectedValue(new Error('load failed')); + const loader = new SkillCommandLoader(mockConfig); + expect(await loader.loadCommands(signal)).toEqual([]); + }); + + describe('extension skills', () => { + it('should be modelInvocable when description is present', async () => { + const skill = makeSkill({ + level: 'extension', + extensionName: 'superpowers-lab', + description: 'Use tmux for interactive commands', + }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'extension' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0].modelInvocable).toBe(true); + expect(commands[0].source).toBe('plugin-command'); + expect(commands[0].sourceLabel).toBe('Extension: superpowers-lab'); + }); + + it('should be modelInvocable when whenToUse is present', async () => { + const skill = makeSkill({ + level: 'extension', + extensionName: 'superpowers-lab', + description: '', + whenToUse: 'Use when you need tmux', + }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'extension' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0].modelInvocable).toBe(true); + }); + + it('should NOT be modelInvocable when description and whenToUse are absent', async () => { + const skill = makeSkill({ + level: 'extension', + extensionName: 'superpowers-lab', + description: '', + whenToUse: undefined, + }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'extension' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0].modelInvocable).toBe(false); + }); + + it('should NOT be modelInvocable when disableModelInvocation is true, even with description', async () => { + const skill = makeSkill({ + level: 'extension', + extensionName: 'superpowers-lab', + description: 'Some description', + disableModelInvocation: true, + }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'extension' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0].modelInvocable).toBe(false); + }); + + it('should use "Extension: unknown" as sourceLabel when extensionName is absent', async () => { + const skill = makeSkill({ level: 'extension', description: 'foo' }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'extension' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0].sourceLabel).toBe('Extension: unknown'); + }); + }); + + describe('user/project skill disableModelInvocation', () => { + it('user skill with disableModelInvocation:true should NOT be modelInvocable', async () => { + const skill = makeSkill({ level: 'user', disableModelInvocation: true }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'user' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0].modelInvocable).toBe(false); + }); + }); + + it('should aggregate skills from all levels', async () => { + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => { + if (level === 'user') + return Promise.resolve([ + makeSkill({ name: 'user-skill', level: 'user' }), + ]); + if (level === 'project') + return Promise.resolve([ + makeSkill({ name: 'proj-skill', level: 'project' }), + ]); + if (level === 'extension') + return Promise.resolve([ + makeSkill({ + name: 'ext-skill', + level: 'extension', + description: 'foo', + }), + ]); + return Promise.resolve([]); + }, + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(3); + expect(commands.map((c) => c.name)).toEqual([ + 'user-skill', + 'proj-skill', + 'ext-skill', + ]); + }); +}); diff --git a/packages/cli/src/services/SkillCommandLoader.ts b/packages/cli/src/services/SkillCommandLoader.ts new file mode 100644 index 000000000..58e297633 --- /dev/null +++ b/packages/cli/src/services/SkillCommandLoader.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '@qwen-code/qwen-code-core'; +import { + createDebugLogger, + appendToLastTextPart, +} from '@qwen-code/qwen-code-core'; +import type { ICommandLoader } from './types.js'; +import type { + SlashCommand, + SlashCommandActionReturn, + CommandSource, +} from '../ui/commands/types.js'; +import { CommandKind } from '../ui/commands/types.js'; + +const debugLogger = createDebugLogger('SKILL_COMMAND_LOADER'); + +/** + * Loads user-level, project-level, and extension-level skills as slash + * commands, making them directly invocable via /. + * + * - User/project skills: always model-invocable (same as bundled), unless + * disable-model-invocation is set. + * - Extension skills: model-invocable only when description or whenToUse is + * present (same rule as plugin commands), unless disable-model-invocation + * is set. + */ +export class SkillCommandLoader implements ICommandLoader { + constructor(private readonly config: Config | null) {} + + async loadCommands(_signal: AbortSignal): Promise { + if (this.config?.getBareMode?.()) { + debugLogger.debug('Bare mode enabled, skipping skill commands'); + return []; + } + + const skillManager = this.config?.getSkillManager(); + if (!skillManager) { + debugLogger.debug('SkillManager not available, skipping skill commands'); + return []; + } + + try { + const [userSkills, projectSkills, extensionSkills] = await Promise.all([ + skillManager.listSkills({ level: 'user' }), + skillManager.listSkills({ level: 'project' }), + skillManager.listSkills({ level: 'extension' }), + ]); + + const allSkills = [...userSkills, ...projectSkills, ...extensionSkills]; + + debugLogger.debug( + `Loaded ${userSkills.length} user + ${projectSkills.length} project + ${extensionSkills.length} extension skill(s) as slash commands`, + ); + + return allSkills.map((skill) => { + const isExtension = skill.level === 'extension'; + + // Extension skills need explicit description or whenToUse to be + // model-invocable (same rule as plugin commands). + // User/project skills are always model-invocable. + const modelInvocable = skill.disableModelInvocation + ? false + : isExtension + ? !!(skill.description || skill.whenToUse) + : true; + + const sourceLabel = isExtension + ? `Extension: ${skill.extensionName ?? 'unknown'}` + : skill.level === 'project' + ? 'Project' + : 'User'; + + return { + name: skill.name, + description: skill.description, + kind: CommandKind.SKILL, + source: (isExtension + ? 'plugin-command' + : 'skill-dir-command') as CommandSource, + sourceLabel, + modelInvocable, + whenToUse: skill.whenToUse, + action: async (context, _args): Promise => { + const body = skill.body; + + const content = context.invocation?.args + ? appendToLastTextPart([{ text: body }], context.invocation.raw) + : [{ text: body }]; + + return { + type: 'submit_prompt', + content, + }; + }, + }; + }); + } catch (error) { + debugLogger.error('Failed to load skill commands:', error); + return []; + } + } +} diff --git a/packages/cli/src/services/command-factory.ts b/packages/cli/src/services/command-factory.ts index 9293e97ac..1aa363254 100644 --- a/packages/cli/src/services/command-factory.ts +++ b/packages/cli/src/services/command-factory.ts @@ -37,6 +37,8 @@ import { AtFileProcessor } from './prompt-processors/atFileProcessor.js'; export interface CommandDefinition { prompt: string; description?: string; + whenToUse?: string; + disableModelInvocation?: boolean; } const debugLogger = createDebugLogger('COMMAND_FACTORY'); @@ -116,8 +118,10 @@ export function createSlashCommandFromDefinition( ? 'plugin-command' : 'skill-dir-command') as CommandSource, sourceLabel: extensionName ? `Plugin: ${extensionName}` : 'Custom', - commandType: 'prompt' as const, - modelInvocable: !extensionName, + modelInvocable: definition.disableModelInvocation + ? false + : !extensionName || !!(definition.description || definition.whenToUse), + whenToUse: definition.whenToUse, action: async ( context: CommandContext, _args: string, diff --git a/packages/cli/src/services/commandUtils.test.ts b/packages/cli/src/services/commandUtils.test.ts index 0287e0a2a..7f6ef24ce 100644 --- a/packages/cli/src/services/commandUtils.test.ts +++ b/packages/cli/src/services/commandUtils.test.ts @@ -22,18 +22,14 @@ function makeCmd(overrides: Partial): SlashCommand { } describe('getEffectiveSupportedModes', () => { - // ── Priority 1: explicit supportedModes ─────────────────────────────── - it('explicit supportedModes overrides commandType inference', () => { - const cmd = makeCmd({ - commandType: 'local', - supportedModes: ['interactive'], - }); + // ── Explicit supportedModes ──────────────────────────────────────────── + it('uses explicit supportedModes when declared', () => { + const cmd = makeCmd({ supportedModes: ['interactive'] }); expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']); }); - it('explicit supportedModes can expand to all modes even for local-jsx', () => { + it('supportedModes can declare all modes', () => { const cmd = makeCmd({ - commandType: 'local-jsx', supportedModes: ['interactive', 'non_interactive', 'acp'], }); expect(getEffectiveSupportedModes(cmd)).toEqual([ @@ -48,45 +44,13 @@ describe('getEffectiveSupportedModes', () => { 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', () => { + // ── CommandKind fallback (no supportedModes) ─────────────────────────── + it('CommandKind.BUILT_IN without supportedModes 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', () => { + it('CommandKind.FILE without supportedModes falls back to all modes', () => { const cmd = makeCmd({ kind: CommandKind.FILE }); expect(getEffectiveSupportedModes(cmd)).toEqual([ 'interactive', @@ -95,7 +59,7 @@ describe('getEffectiveSupportedModes', () => { ]); }); - it('no commandType, CommandKind.SKILL falls back to all modes', () => { + it('CommandKind.SKILL without supportedModes falls back to all modes', () => { const cmd = makeCmd({ kind: CommandKind.SKILL }); expect(getEffectiveSupportedModes(cmd)).toEqual([ 'interactive', @@ -104,7 +68,7 @@ describe('getEffectiveSupportedModes', () => { ]); }); - it('no commandType, CommandKind.MCP_PROMPT falls back to all modes (fixes original bug)', () => { + it('CommandKind.MCP_PROMPT without supportedModes falls back to all modes (fixes original bug)', () => { const cmd = makeCmd({ kind: CommandKind.MCP_PROMPT }); expect(getEffectiveSupportedModes(cmd)).toEqual([ 'interactive', @@ -118,28 +82,26 @@ 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 + supportedModes: ['interactive'], }), makeCmd({ name: 'review', kind: CommandKind.SKILL, - commandType: 'prompt', + supportedModes: ['interactive', 'non_interactive', 'acp'], }), makeCmd({ name: 'gh-prompt', kind: CommandKind.MCP_PROMPT, - commandType: 'prompt', + supportedModes: ['interactive', 'non_interactive', 'acp'], }), makeCmd({ name: 'my-script', kind: CommandKind.FILE, - commandType: 'prompt', + supportedModes: ['interactive', 'non_interactive', 'acp'], }), ]; @@ -154,7 +116,7 @@ describe('filterCommandsForMode', () => { ]); }); - it('non_interactive mode excludes local-jsx commands', () => { + it('non_interactive mode excludes interactive-only commands', () => { const result = filterCommandsForMode(commands, 'non_interactive'); expect(result.map((c) => c.name)).toEqual([ 'init', @@ -164,7 +126,7 @@ describe('filterCommandsForMode', () => { ]); }); - it('acp mode excludes local-jsx commands', () => { + it('acp mode excludes interactive-only commands', () => { const result = filterCommandsForMode(commands, 'acp'); expect(result.map((c) => c.name)).toEqual([ 'init', @@ -182,20 +144,22 @@ describe('filterCommandsForMode', () => { it('does not filter hidden commands (hidden filtering is caller responsibility)', () => { const withHidden = [ ...commands, - makeCmd({ name: 'hidden-cmd', commandType: 'local', hidden: true }), + makeCmd({ + name: 'hidden-cmd', + hidden: true, + // no supportedModes → BUILT_IN fallback → interactive only + }), ]; 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', () => { + it('hidden command with explicit all-mode supportedModes still passes mode filter', () => { const withHidden = [ ...commands, makeCmd({ name: 'hidden-cmd', - commandType: 'local', hidden: true, supportedModes: ['interactive', 'non_interactive', 'acp'], }), @@ -206,7 +170,9 @@ describe('filterCommandsForMode', () => { }); it('returns empty array when no commands match', () => { - const jsxOnly = [makeCmd({ name: 'model', commandType: 'local-jsx' })]; + const jsxOnly = [ + makeCmd({ name: 'model', supportedModes: ['interactive'] }), + ]; expect(filterCommandsForMode(jsxOnly, 'non_interactive')).toEqual([]); }); }); diff --git a/packages/cli/src/services/commandUtils.ts b/packages/cli/src/services/commandUtils.ts index ddd60aae3..3793c8491 100644 --- a/packages/cli/src/services/commandUtils.ts +++ b/packages/cli/src/services/commandUtils.ts @@ -20,50 +20,29 @@ import { /** * 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) + * All commands must explicitly declare `supportedModes` (Phase 2+ requirement). + * If a command omits it, this function falls back to a conservative default + * based on `CommandKind` — built-in commands default to interactive-only, + * while file/skill/mcp-prompt commands default to all modes. * * @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 + // Explicit declaration is always authoritative. 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. + // Fallback based on CommandKind for commands that omit supportedModes. + // Built-in commands without a declaration are conservative (interactive only). + // File / skill / MCP-prompt commands retain their historical all-mode behavior. 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']; diff --git a/packages/cli/src/services/markdown-command-parser.ts b/packages/cli/src/services/markdown-command-parser.ts index 5d4a3b7df..bfe5a3e2b 100644 --- a/packages/cli/src/services/markdown-command-parser.ts +++ b/packages/cli/src/services/markdown-command-parser.ts @@ -18,7 +18,10 @@ export const MarkdownCommandDefSchema = z.object({ frontmatter: z .object({ description: z.string().optional(), + when_to_use: z.string().optional(), + 'disable-model-invocation': z.boolean().optional(), }) + .passthrough() .optional(), prompt: z.string({ required_error: 'The prompt content is required.', diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 332b4114b..a63ebb484 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -29,6 +29,7 @@ export const createMockCommandContext = ( overrides: DeepPartial = {}, ): CommandContext => { const defaultMocks: CommandContext = { + executionMode: 'interactive', invocation: { raw: '', name: '', diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts index 4920f2404..53371e629 100644 --- a/packages/cli/src/ui/commands/aboutCommand.test.ts +++ b/packages/cli/src/ui/commands/aboutCommand.test.ts @@ -280,4 +280,65 @@ describe('aboutCommand', () => { expect.any(Number), ); }); + + describe('non-interactive mode', () => { + it('should return text summary without calling addItem', async () => { + if (!aboutCommand.action) { + throw new Error('The about command must have an action.'); + } + + const nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + } as unknown as Partial); + // Attach a spy to the non-interactive context's ui + nonInteractiveContext.ui.addItem = vi.fn(); + + const result = await aboutCommand.action(nonInteractiveContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('test-version'), + }); + expect(result).toEqual( + expect.objectContaining({ + content: expect.stringContaining('test-model'), + }), + ); + expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled(); + }); + + it('should include git commit and IDE when available', async () => { + if (!aboutCommand.action) throw new Error('No action'); + + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'vscode', + sessionId: 'sess-1', + memoryUsage: '100 MB', + baseUrl: undefined, + gitCommit: 'abc1234', + }); + + const nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + } as unknown as Partial); + + const result = (await aboutCommand.action(nonInteractiveContext, '')) as { + type: string; + content: string; + }; + + expect(result.content).toContain('abc1234'); + expect(result.content).toContain('vscode'); + }); + }); }); diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index 60fce9f96..d6bcbcca3 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -17,15 +17,37 @@ export const aboutCommand: SlashCommand = { return t('show version info'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context) => { const systemInfo = await getExtendedSystemInfo(context); + if (context.executionMode !== 'interactive') { + const lines = [ + `Qwen Code v${systemInfo.cliVersion}`, + `Model: ${systemInfo.modelVersion}`, + `Fast Model: ${systemInfo.fastModel ?? 'not set'}`, + `Auth: ${systemInfo.selectedAuthType}`, + `Platform: ${systemInfo.osPlatform} ${systemInfo.osArch} (${systemInfo.osRelease})`, + `Node.js: ${systemInfo.nodeVersion}`, + `Session: ${systemInfo.sessionId}`, + ...(systemInfo.gitCommit + ? [`Git commit: ${systemInfo.gitCommit}`] + : []), + ...(systemInfo.ideClient ? [`IDE: ${systemInfo.ideClient}`] : []), + ]; + return { + type: 'message' as const, + messageType: 'info' as const, + content: lines.join('\n'), + }; + } + const aboutItem: Omit = { type: MessageType.ABOUT, systemInfo, }; context.ui.addItem(aboutItem, Date.now()); + return; }, }; diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts index 94b8d2cb0..118cc9c55 100644 --- a/packages/cli/src/ui/commands/agentsCommand.ts +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -17,7 +17,7 @@ export const agentsCommand: SlashCommand = { return t('Manage subagents for specialized task delegation.'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, subCommands: [ { name: 'manage', @@ -25,7 +25,7 @@ export const agentsCommand: SlashCommand = { return t('Manage existing subagents (view, edit, delete).'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: (): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'subagent_list', @@ -37,7 +37,7 @@ export const agentsCommand: SlashCommand = { return t('Create a new subagent with guided setup.'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, 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 b66cc8a38..e96695680 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.ts @@ -34,14 +34,15 @@ export const approvalModeCommand: SlashCommand = { return t('View or change the approval mode for tool usage'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, args: string, ): Promise => { const mode = parseApprovalModeArg(args); - // If no argument provided, open the dialog + // If no argument provided, open dialog in interactive mode; + // in non-interactive/ACP, return current state instead if (!args.trim()) { return { type: 'dialog', diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index 4a50ec5b0..1ce8b94e2 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -387,14 +387,14 @@ export const arenaCommand: SlashCommand = { name: 'arena', description: 'Manage Arena sessions', kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, subCommands: [ { name: 'start', description: 'Start an Arena session with multiple models competing on the same task', kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, args: string, @@ -451,7 +451,7 @@ export const arenaCommand: SlashCommand = { name: 'stop', description: 'Stop the current Arena session', kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, ): Promise => { @@ -493,7 +493,7 @@ export const arenaCommand: SlashCommand = { name: 'status', description: 'Show the current Arena session status', kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, ): Promise => { @@ -536,7 +536,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', + supportedModes: ['interactive'] as const, 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 e7abf552f..73e3e029d 100644 --- a/packages/cli/src/ui/commands/authCommand.ts +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -15,7 +15,7 @@ export const authCommand: SlashCommand = { return t('Configure authentication information for login'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: (_context, _args): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'auth', diff --git a/packages/cli/src/ui/commands/btwCommand.test.ts b/packages/cli/src/ui/commands/btwCommand.test.ts index 5d2b42572..5aebbbb1a 100644 --- a/packages/cli/src/ui/commands/btwCommand.test.ts +++ b/packages/cli/src/ui/commands/btwCommand.test.ts @@ -434,109 +434,4 @@ describe('btwCommand', () => { expect(mockContext.ui.setBtwItem).toHaveBeenCalledTimes(2); }); }); - - describe('non-interactive mode', () => { - let nonInteractiveContext: CommandContext; - - beforeEach(() => { - nonInteractiveContext = createMockCommandContext({ - executionMode: 'non_interactive', - services: { - config: createConfig(), - }, - }); - }); - - it('should return info message on success', async () => { - mockRunForkedAgent.mockResolvedValue({ - text: 'the answer', - usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 }, - }); - - const result = await btwCommand.action!( - nonInteractiveContext, - 'my question', - ); - - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: 'btw> my question\nthe answer', - }); - }); - - it('should return error message on failure', async () => { - mockRunForkedAgent.mockRejectedValue(new Error('network error')); - - const result = await btwCommand.action!( - nonInteractiveContext, - 'my question', - ); - - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Failed to answer btw question: network error', - }); - }); - }); - - describe('acp mode', () => { - let acpContext: CommandContext; - - beforeEach(() => { - acpContext = createMockCommandContext({ - executionMode: 'acp', - services: { - config: createConfig(), - }, - }); - }); - - it('should return stream_messages generator on success', async () => { - mockRunForkedAgent.mockResolvedValue({ - text: 'streamed answer', - usage: { inputTokens: 5, outputTokens: 3, cacheHitTokens: 0 }, - }); - - const result = (await btwCommand.action!(acpContext, 'my question')) as { - type: string; - messages: AsyncGenerator; - }; - - expect(result.type).toBe('stream_messages'); - - const messages = []; - for await (const msg of result.messages) { - messages.push(msg); - } - - expect(messages).toEqual([ - { messageType: 'info', content: 'Thinking...' }, - { messageType: 'info', content: 'btw> my question\nstreamed answer' }, - ]); - }); - - it('should yield error message on failure', async () => { - mockRunForkedAgent.mockRejectedValue(new Error('api failure')); - - const result = (await btwCommand.action!(acpContext, 'my question')) as { - type: string; - messages: AsyncGenerator; - }; - - const messages = []; - for await (const msg of result.messages) { - messages.push(msg); - } - - expect(messages).toEqual([ - { messageType: 'info', content: 'Thinking...' }, - { - messageType: 'error', - content: 'Failed to answer btw question: api failure', - }, - ]); - }); - }); }); diff --git a/packages/cli/src/ui/commands/btwCommand.ts b/packages/cli/src/ui/commands/btwCommand.ts index 80d0c6260..801b6a275 100644 --- a/packages/cli/src/ui/commands/btwCommand.ts +++ b/packages/cli/src/ui/commands/btwCommand.ts @@ -123,15 +123,12 @@ export const btwCommand: SlashCommand = { ); }, kind: CommandKind.BUILT_IN, - commandType: 'local', - supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, args: string, ): Promise => { const question = args.trim(); - const executionMode = context.executionMode ?? 'interactive'; - const abortSignal = context.abortSignal ?? new AbortController().signal; if (!question) { return { @@ -152,53 +149,17 @@ export const btwCommand: SlashCommand = { }; } - // ACP mode: return a stream_messages async generator - if (executionMode === 'acp') { - const messages = async function* () { - try { - yield { - messageType: 'info' as const, - content: t('Thinking...'), - }; - - const answer = await askBtw(context, question, abortSignal); - - yield { - messageType: 'info' as const, - content: `btw> ${question}\n${answer}`, - }; - } catch (error) { - yield { - messageType: 'error' as const, - content: formatBtwError(error), - }; - } + const model = config.getModel(); + if (!model) { + return { + type: 'message', + messageType: 'error', + content: t('No model configured.'), }; - - return { type: 'stream_messages', messages: messages() }; - } - - // Non-interactive mode: return a simple message result - if (executionMode === 'non_interactive') { - try { - const answer = await askBtw(context, question, abortSignal); - return { - type: 'message', - messageType: 'info', - content: `btw> ${question}\n${answer}`, - }; - } catch (error) { - return { - type: 'message', - messageType: 'error', - content: formatBtwError(error), - }; - } } // Interactive mode: use dedicated btwItem state for the fixed bottom area. // This does NOT occupy pendingItem, so the main conversation is never blocked. - // Cancel any previous in-flight btw before starting a new one. ui.cancelBtw(); diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 543741b13..ffd932f1b 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -21,7 +21,6 @@ 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(); diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 61e66b53e..6b84ededc 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -227,4 +227,65 @@ describe('clearCommand', () => { expect(mockResetChat).not.toHaveBeenCalled(); expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1); }); + + describe('non-interactive mode', () => { + let nonInteractiveContext: ReturnType; + + beforeEach(() => { + nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + services: { + config: { + getHookSystem: mockGetHookSystem, + startNewSession: mockStartNewSession, + getGeminiClient: vi.fn().mockReturnValue({ + resetChat: mockResetChat, + } as unknown as GeminiClient), + getModel: vi.fn().mockReturnValue('test-model'), + getApprovalMode: vi.fn().mockReturnValue('default'), + getToolRegistry: vi.fn().mockReturnValue({ + getAllTools: vi.fn().mockReturnValue([]), + }), + }, + }, + session: { + startNewSession: vi.fn(), + }, + }); + }); + + it('should return context boundary message in non-interactive mode', async () => { + if (!clearCommand.action) + throw new Error('clearCommand must have an action.'); + + const result = await clearCommand.action(nonInteractiveContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Context cleared. Previous messages are no longer in context.', + }); + }); + + it('should still call resetChat in non-interactive mode', async () => { + if (!clearCommand.action) + throw new Error('clearCommand must have an action.'); + + await clearCommand.action(nonInteractiveContext, ''); + + expect(mockResetChat).toHaveBeenCalledTimes(1); + }); + + it('should still fire session events in non-interactive mode', async () => { + if (!clearCommand.action) + throw new Error('clearCommand must have an action.'); + + await clearCommand.action(nonInteractiveContext, ''); + + expect(mockFireSessionEndEvent).toHaveBeenCalledWith( + SessionEndReason.Clear, + ); + expect(mockFireSessionStartEvent).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index f55b3beed..dbf9a0d41 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -22,7 +22,7 @@ export const clearCommand: SlashCommand = { return t('Clear conversation history and free up context'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context, _args) => { const { config } = context.services; @@ -83,5 +83,14 @@ export const clearCommand: SlashCommand = { context.ui.setDebugMessage(t('Starting a new session and clearing.')); context.ui.clear(); } + + if (context.executionMode !== 'interactive') { + return { + type: 'message' as const, + messageType: 'info' as const, + content: 'Context cleared. Previous messages are no longer in context.', + }; + } + return; }, }; diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index b3cbd8c8b..4178bf1be 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -17,7 +17,6 @@ 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; diff --git a/packages/cli/src/ui/commands/contextCommand.ts b/packages/cli/src/ui/commands/contextCommand.ts index 67a4fc611..af3973396 100644 --- a/packages/cli/src/ui/commands/contextCommand.ts +++ b/packages/cli/src/ui/commands/contextCommand.ts @@ -308,6 +308,166 @@ export async function collectContextData( }; } +/** + * Format token count for display (e.g. 1234 -> "1.2k", 123456 -> "123.5k") + */ +function fmtTokens(tokens: number): string { + if (tokens >= 1000) { + return `${(tokens / 1000).toFixed(1)}k`; + } + return `${tokens}`; +} + +/** + * Format a category row as text: " label .............. 1.2k tokens (3.4%)" + */ +function fmtCategoryRow( + label: string, + tokens: number, + contextWindowSize: number, + indent = ' ', +): string { + const percentage = ((tokens / contextWindowSize) * 100).toFixed(1); + const right = `${fmtTokens(tokens)} tokens (${percentage}%)`; + const leftPart = `${indent}${label}`; + const totalWidth = 56; + const dots = Math.max(1, totalWidth - leftPart.length - right.length); + return `${leftPart}${' '.repeat(dots)}${right}`; +} + +/** + * Convert a HistoryItemContextUsage to a human-readable text string, + * mirroring the layout of the interactive ContextUsage component. + */ +export function formatContextUsageText(data: HistoryItemContextUsage): string { + const { + modelName, + totalTokens, + contextWindowSize, + breakdown, + builtinTools, + mcpTools, + memoryFiles, + skills, + isEstimated, + showDetails, + } = data; + + const lines: string[] = []; + lines.push('## Context Usage'); + lines.push(''); + + if (isEstimated) { + lines.push('*No API response yet. Send a message to see actual usage.*'); + lines.push(''); + lines.push('**Estimated pre-conversation overhead**'); + lines.push( + `Model: ${modelName} Context window: ${fmtTokens(contextWindowSize)} tokens`, + ); + lines.push(''); + } else { + lines.push( + `Model: ${modelName} Context window: ${fmtTokens(contextWindowSize)} tokens`, + ); + lines.push(''); + lines.push(fmtCategoryRow('Used', totalTokens, contextWindowSize)); + lines.push(fmtCategoryRow('Free', breakdown.freeSpace, contextWindowSize)); + lines.push( + fmtCategoryRow( + 'Autocompact buffer', + breakdown.autocompactBuffer, + contextWindowSize, + ), + ); + lines.push(''); + lines.push('**Usage by category**'); + } + + lines.push( + fmtCategoryRow('System prompt', breakdown.systemPrompt, contextWindowSize), + ); + lines.push( + fmtCategoryRow('Built-in tools', breakdown.builtinTools, contextWindowSize), + ); + if (breakdown.mcpTools > 0) { + lines.push( + fmtCategoryRow('MCP tools', breakdown.mcpTools, contextWindowSize), + ); + } + lines.push( + fmtCategoryRow('Memory files', breakdown.memoryFiles, contextWindowSize), + ); + lines.push(fmtCategoryRow('Skills', breakdown.skills, contextWindowSize)); + if (!isEstimated) { + lines.push( + fmtCategoryRow('Messages', breakdown.messages, contextWindowSize), + ); + } + + if (showDetails) { + const sortedBuiltin = [...builtinTools].sort((a, b) => b.tokens - a.tokens); + const sortedMcp = [...mcpTools].sort((a, b) => b.tokens - a.tokens); + const sortedMemory = [...memoryFiles].sort((a, b) => b.tokens - a.tokens); + const sortedSkills = [...skills].sort((a, b) => { + if (a.loaded !== b.loaded) return a.loaded ? -1 : 1; + return b.tokens + (b.bodyTokens ?? 0) - (a.tokens + (a.bodyTokens ?? 0)); + }); + + if (sortedBuiltin.length > 0) { + lines.push(''); + lines.push('**Built-in tools**'); + for (const tool of sortedBuiltin) { + lines.push( + fmtCategoryRow(tool.name, tool.tokens, contextWindowSize, ' └ '), + ); + } + } + if (sortedMcp.length > 0) { + lines.push(''); + lines.push('**MCP tools**'); + for (const tool of sortedMcp) { + lines.push( + fmtCategoryRow(tool.name, tool.tokens, contextWindowSize, ' └ '), + ); + } + } + if (sortedMemory.length > 0) { + lines.push(''); + lines.push('**Memory files**'); + for (const file of sortedMemory) { + lines.push( + fmtCategoryRow(file.path, file.tokens, contextWindowSize, ' └ '), + ); + } + } + if (sortedSkills.length > 0) { + lines.push(''); + lines.push('**Skills**'); + for (const skill of sortedSkills) { + const label = skill.loaded ? `${skill.name} (active)` : skill.name; + lines.push( + fmtCategoryRow(label, skill.tokens, contextWindowSize, ' └ '), + ); + if (skill.loaded && skill.bodyTokens && skill.bodyTokens > 0) { + lines.push( + fmtCategoryRow( + 'body loaded', + skill.bodyTokens, + contextWindowSize, + ' └ ', + ), + ); + } + } + } + } else { + lines.push(''); + lines.push('*Run /context detail for per-item breakdown.*'); + } + + return lines.join('\n'); +} + export const contextCommand: SlashCommand = { name: 'context', get description() { @@ -316,7 +476,6 @@ 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 = @@ -351,7 +510,7 @@ export const contextCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: JSON.stringify(contextUsageItem, null, 2), + content: formatContextUsageText(contextUsageItem), }; } }, @@ -362,7 +521,6 @@ 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 diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 4eec750cc..634986902 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -15,7 +15,7 @@ export const copyCommand: SlashCommand = { return t('Copy the last result or code snippet to clipboard'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, 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 70acf6463..d28e7a3b9 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -74,7 +74,7 @@ export const directoryCommand: SlashCommand = { return t('Manage workspace directories'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, subCommands: [ { name: 'add', @@ -84,7 +84,7 @@ export const directoryCommand: SlashCommand = { ); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, completion: async (_context: CommandContext, partialArg: string) => getDirPathCompletions(partialArg), action: async (context: CommandContext, args: string) => { @@ -224,7 +224,7 @@ export const directoryCommand: SlashCommand = { return t('Show all directories in the workspace'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (context: CommandContext) => { const { ui: { addItem }, diff --git a/packages/cli/src/ui/commands/docsCommand.test.ts b/packages/cli/src/ui/commands/docsCommand.test.ts index b38b64dd6..fce3c29fa 100644 --- a/packages/cli/src/ui/commands/docsCommand.test.ts +++ b/packages/cli/src/ui/commands/docsCommand.test.ts @@ -96,4 +96,24 @@ describe('docsCommand', () => { // 'open' should be called in this specific sandbox case expect(open).toHaveBeenCalledWith(docsUrl); }); + + describe('non-interactive mode', () => { + it('should return docs URL without opening browser', async () => { + if (!docsCommand.action) throw new Error('Command has no action'); + + const nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + }); + + const result = await docsCommand.action(nonInteractiveContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('qwenlm.github.io'), + }); + expect(open).not.toHaveBeenCalled(); + expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/ui/commands/docsCommand.ts b/packages/cli/src/ui/commands/docsCommand.ts index bde817c7e..6de9c492c 100644 --- a/packages/cli/src/ui/commands/docsCommand.ts +++ b/packages/cli/src/ui/commands/docsCommand.ts @@ -20,11 +20,20 @@ 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 => { + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: async (context: CommandContext) => { const langPath = getCurrentLanguage()?.startsWith('zh') ? 'zh' : 'en'; const docsUrl = `https://qwenlm.github.io/qwen-code-docs/${langPath}`; + // Non-interactive/ACP: return URL directly, no browser, no addItem + if (context.executionMode !== 'interactive') { + return { + type: 'message' as const, + messageType: 'info' as const, + content: `Qwen Code documentation: ${docsUrl}`, + }; + } + if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { context.ui.addItem( { @@ -50,5 +59,6 @@ export const docsCommand: SlashCommand = { ); await open(docsUrl); } + return; }, }; diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts index d44d9ed5c..975a319ca 100644 --- a/packages/cli/src/ui/commands/doctorCommand.ts +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -16,6 +16,7 @@ export const doctorCommand: SlashCommand = { return t('Run installation and environment diagnostics'); }, kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context) => { const executionMode = context.executionMode ?? 'interactive'; const abortSignal = context.abortSignal; diff --git a/packages/cli/src/ui/commands/editorCommand.ts b/packages/cli/src/ui/commands/editorCommand.ts index 54a4097a9..9ecf5ba99 100644 --- a/packages/cli/src/ui/commands/editorCommand.ts +++ b/packages/cli/src/ui/commands/editorCommand.ts @@ -17,7 +17,7 @@ export const editorCommand: SlashCommand = { return t('set external editor preference'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, 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 a7cc8f8bc..b86e8aa6e 100644 --- a/packages/cli/src/ui/commands/exportCommand.ts +++ b/packages/cli/src/ui/commands/exportCommand.ts @@ -325,7 +325,8 @@ export const exportCommand: SlashCommand = { return t('Export current session message history to a file'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: exportHtmlAction, subCommands: [ { name: 'html', @@ -333,7 +334,7 @@ export const exportCommand: SlashCommand = { return t('Export session to HTML format'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: exportHtmlAction, }, { @@ -342,7 +343,7 @@ export const exportCommand: SlashCommand = { return t('Export session to markdown format'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: exportMarkdownAction, }, { @@ -351,7 +352,7 @@ export const exportCommand: SlashCommand = { return t('Export session to JSON format'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: exportJsonAction, }, { @@ -360,7 +361,7 @@ export const exportCommand: SlashCommand = { return t('Export session to JSONL format (one message per line)'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: exportJsonlAction, }, ], diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index a8661400a..49702e662 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -216,7 +216,7 @@ const exploreExtensionsCommand: SlashCommand = { return t('Open extensions page in your browser'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: exploreAction, completion: completeExtensionsExplore, }; @@ -227,7 +227,7 @@ const manageExtensionsCommand: SlashCommand = { return t('Manage installed extensions'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: listAction, }; @@ -237,7 +237,7 @@ const installCommand: SlashCommand = { return t('Install an extension from a git repo or local path'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: installAction, }; @@ -247,7 +247,7 @@ export const extensionsCommand: SlashCommand = { return t('Manage extensions'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, subCommands: [ manageExtensionsCommand, installCommand, diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts index 0c4d528a3..659158b7c 100644 --- a/packages/cli/src/ui/commands/helpCommand.ts +++ b/packages/cli/src/ui/commands/helpCommand.ts @@ -13,7 +13,7 @@ export const helpCommand: SlashCommand = { name: 'help', altNames: ['?'], kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, 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 f6b28fefe..c0474a756 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -43,7 +43,7 @@ const listCommand: SlashCommand = { return t('List all configured hooks'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, _args: string, @@ -186,7 +186,7 @@ export const hooksCommand: SlashCommand = { return t('Manage Qwen Code hooks'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, 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 9d75fcc34..5b9919067 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -143,7 +143,7 @@ export const ideCommand = async (): Promise => { return t('manage IDE integration'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: (): SlashCommandActionReturn => ({ type: 'message', @@ -161,7 +161,7 @@ export const ideCommand = async (): Promise => { return t('manage IDE integration'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, subCommands: [], }; @@ -171,7 +171,7 @@ export const ideCommand = async (): Promise => { return t('check status of IDE integration'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (): Promise => { const { messageType, content } = await getIdeStatusMessageWithFiles(ideClient); @@ -192,7 +192,7 @@ export const ideCommand = async (): Promise => { }); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (context) => { const installer = getIdeInstaller(currentIDE); const isSandBox = !!process.env['SANDBOX']; @@ -280,7 +280,7 @@ export const ideCommand = async (): Promise => { return t('enable IDE integration'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (context: CommandContext) => { context.services.settings.setValue( SettingScope.User, @@ -305,7 +305,7 @@ export const ideCommand = async (): Promise => { return t('disable IDE integration'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, 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 cf6af99bb..15f89de82 100644 --- a/packages/cli/src/ui/commands/initCommand.ts +++ b/packages/cli/src/ui/commands/initCommand.ts @@ -23,7 +23,6 @@ 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, diff --git a/packages/cli/src/ui/commands/insightCommand.test.ts b/packages/cli/src/ui/commands/insightCommand.test.ts index 272ce9d73..43752ec4c 100644 --- a/packages/cli/src/ui/commands/insightCommand.test.ts +++ b/packages/cli/src/ui/commands/insightCommand.test.ts @@ -171,4 +171,62 @@ describe('insightCommand', () => { ), }); }); + + it('non_interactive: returns message with output path and does not open browser', async () => { + const nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + services: { + config: {} as CommandContext['services']['config'], + }, + ui: { + addItem: vi.fn(), + setPendingItem: vi.fn(), + setDebugMessage: vi.fn(), + }, + } as unknown as CommandContext); + + if (!insightCommand.action) { + throw new Error('insight command must have action'); + } + + const result = await insightCommand.action(nonInteractiveContext, ''); + + expect(result).toMatchObject({ + type: 'message', + messageType: 'info', + }); + expect((result as { content: string }).content).toContain( + path.resolve('runtime-output', 'insights', 'insight-2026-03-05.html'), + ); + expect(open).not.toHaveBeenCalled(); + }); + + it('non_interactive: returns error message when generation fails', async () => { + mockGenerateStaticInsight.mockRejectedValue(new Error('disk full')); + + const nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + services: { + config: {} as CommandContext['services']['config'], + }, + ui: { + addItem: vi.fn(), + setPendingItem: vi.fn(), + setDebugMessage: vi.fn(), + }, + } as unknown as CommandContext); + + if (!insightCommand.action) { + throw new Error('insight command must have action'); + } + + const result = await insightCommand.action(nonInteractiveContext, ''); + + expect(result).toMatchObject({ + type: 'message', + messageType: 'error', + }); + expect((result as { content: string }).content).toContain('disk full'); + expect(open).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/ui/commands/insightCommand.ts b/packages/cli/src/ui/commands/insightCommand.ts index 5c957d1df..6f1ed8915 100644 --- a/packages/cli/src/ui/commands/insightCommand.ts +++ b/packages/cli/src/ui/commands/insightCommand.ts @@ -29,19 +29,53 @@ export const insightCommand: SlashCommand = { ); }, kind: CommandKind.BUILT_IN, - commandType: 'local', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context: CommandContext) => { try { context.ui.setDebugMessage(t('Generating insights...')); const projectsDir = join(Storage.getRuntimeBaseDir(), 'projects'); if (!context.services.config) { + if (context.executionMode !== 'interactive') { + return { + type: 'message' as const, + messageType: 'error' as const, + content: 'Config service is not available.', + }; + } throw new Error('Config service is not available'); } const insightGenerator = new StaticInsightGenerator( context.services.config, ); + if (context.executionMode === 'non_interactive') { + // non_interactive: run synchronously and return a single message + try { + const outputPath = await insightGenerator.generateStaticInsight( + projectsDir, + () => { + // progress callback is no-op in non_interactive mode + }, + ); + return { + type: 'message' as const, + messageType: 'info' as const, + content: t('Insight report generated at: {{path}}', { + path: outputPath, + }), + }; + } catch (error) { + return { + type: 'message' as const, + messageType: 'error' as const, + content: t('Failed to generate insights: {{error}}', { + error: (error as Error).message, + }), + }; + } + } + if (context.executionMode === 'acp') { const pendingMessages: Array<{ messageType: 'info' | 'error'; @@ -215,6 +249,14 @@ export const insightCommand: SlashCommand = { } catch (error) { context.ui.setPendingItem(null); + if (context.executionMode !== 'interactive') { + return { + type: 'message' as const, + messageType: 'error' as const, + content: `Failed to generate insights: ${(error as Error).message}`, + }; + } + context.ui.addItem( { type: MessageType.ERROR, @@ -228,5 +270,6 @@ export const insightCommand: SlashCommand = { logger.error('Insight generation error:', error); return; } + return; }, }; diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index c459959be..7a5834a81 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -183,7 +183,7 @@ export const languageCommand: SlashCommand = { return t('View or change the language setting'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async ( context: CommandContext, @@ -269,7 +269,7 @@ export const languageCommand: SlashCommand = { return t('Set UI language'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async ( context: CommandContext, @@ -324,7 +324,7 @@ export const languageCommand: SlashCommand = { }); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context, args) => { if (args.trim()) { return { @@ -348,7 +348,7 @@ export const languageCommand: SlashCommand = { return t('Set LLM output language'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async ( context: CommandContext, diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index a751c5abd..6e3c3d0d0 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -14,7 +14,7 @@ export const mcpCommand: SlashCommand = { return t('Open MCP management dialog'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (): Promise => ({ type: 'dialog', dialog: 'mcp', diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index fc10061a3..65c27a601 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -14,7 +14,7 @@ export const memoryCommand: SlashCommand = { return t('Open the memory manager.'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async () => ({ type: 'dialog', dialog: 'memory', diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index b39f1a6aa..7da549519 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -139,4 +139,57 @@ describe('modelCommand', () => { content: 'Authentication type not available.', }); }); + + describe('non-interactive mode', () => { + it('should return current model without triggering dialog when no args', async () => { + mockContext = createMockCommandContext({ + executionMode: 'non_interactive', + services: { + config: { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + model: 'qwen-max', + authType: AuthType.QWEN_OAUTH, + }), + getModel: vi.fn().mockReturnValue('qwen-max'), + }, + }, + }); + + const result = await modelCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('qwen-max'), + }); + expect((result as { type: string }).type).toBe('message'); + }); + + it('should return current fast model without triggering dialog for --fast no args', async () => { + mockContext = createMockCommandContext({ + executionMode: 'non_interactive', + invocation: { args: '--fast' }, + services: { + config: { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + model: 'qwen-max', + authType: AuthType.QWEN_OAUTH, + }), + getModel: vi.fn().mockReturnValue('qwen-max'), + }, + settings: { + merged: { fastModel: 'qwen-turbo' } as Record, + }, + }, + }); + + const result = await modelCommand.action!(mockContext, '--fast'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('qwen-turbo'), + }); + }); + }); }); diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index c0af230bd..1ee5e6083 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -21,7 +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', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, completion: async (_context, partialArg) => { if (partialArg && '--fast'.startsWith(partialArg)) { return [ @@ -54,7 +54,16 @@ export const modelCommand: SlashCommand = { if (args.startsWith('--fast')) { const modelName = args.replace('--fast', '').trim(); if (!modelName) { - // Open model dialog in fast-model mode + // Open model dialog in fast-model mode (interactive) or return current fast model (non-interactive) + if (context.executionMode !== 'interactive') { + const fastModel = + context.services.settings?.merged?.fastModel ?? 'not set'; + return { + type: 'message', + messageType: 'info', + content: `Current fast model: ${fastModel}\nUse "/model --fast " to set fast model.`, + }; + } return { type: 'dialog', dialog: 'fast-model', @@ -101,6 +110,39 @@ export const modelCommand: SlashCommand = { }; } + // Non-interactive/ACP: set model if an arg was provided, otherwise show current model + if (context.executionMode !== 'interactive') { + const modelName = args.trim(); + if (modelName) { + // /model — set the main model + if (!settings) { + return { + type: 'message', + messageType: 'error', + content: t('Settings service not available.'), + }; + } + settings.setValue( + getPersistScopeForModelSelection(settings), + 'model.name', + modelName, + ); + await config.setModel(modelName); + return { + type: 'message', + messageType: 'info', + content: t('Model') + ': ' + modelName, + }; + } + // /model with no args — show current model + const currentModel = config.getModel() ?? 'unknown'; + return { + type: 'message', + messageType: 'info', + content: `Current model: ${currentModel}\nUse "/model " to switch models or "/model --fast " to set the fast model.`, + }; + } + return { type: 'dialog', dialog: 'model', diff --git a/packages/cli/src/ui/commands/permissionsCommand.ts b/packages/cli/src/ui/commands/permissionsCommand.ts index f1ab21915..e96448b85 100644 --- a/packages/cli/src/ui/commands/permissionsCommand.ts +++ b/packages/cli/src/ui/commands/permissionsCommand.ts @@ -14,7 +14,7 @@ export const permissionsCommand: SlashCommand = { return t('Manage permission rules'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, 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 6dba0f0c6..8839cd809 100644 --- a/packages/cli/src/ui/commands/planCommand.ts +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -20,7 +20,7 @@ export const planCommand: SlashCommand = { return t('Switch to plan mode or exit plan mode'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', + supportedModes: ['interactive'] as const, 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 9d7f7623d..6d0ef5e67 100644 --- a/packages/cli/src/ui/commands/quitCommand.ts +++ b/packages/cli/src/ui/commands/quitCommand.ts @@ -15,7 +15,7 @@ export const quitCommand: SlashCommand = { return t('exit the cli'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, 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 051627c5f..827f92fd8 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -151,7 +151,7 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => { ); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: restoreAction, completion, }; diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts index 8046e52fa..4644d3394 100644 --- a/packages/cli/src/ui/commands/resumeCommand.ts +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -13,7 +13,7 @@ export const resumeCommand: SlashCommand = { name: 'resume', altNames: ['continue'], kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, 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 0f9e79344..37f7002e7 100644 --- a/packages/cli/src/ui/commands/settingsCommand.ts +++ b/packages/cli/src/ui/commands/settingsCommand.ts @@ -14,7 +14,7 @@ export const settingsCommand: SlashCommand = { return t('View and edit Qwen Code settings'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, 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 2102d30d7..aabbbff8e 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -99,7 +99,7 @@ export const setupGithubCommand: SlashCommand = { return t('Set up GitHub Actions'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, ): Promise => { diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 79b45e4e5..801da28c9 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -24,7 +24,7 @@ export const skillsCommand: SlashCommand = { return t('List available skills.'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (context: CommandContext, args?: string) => { const rawArgs = args?.trim() ?? ''; const [skillName = ''] = rawArgs.split(/\s+/); diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index 485fcf693..356422dfd 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -75,4 +75,75 @@ describe('statsCommand', () => { expect.any(Number), ); }); + + describe('non-interactive mode', () => { + let nonInteractiveContext: ReturnType; + + beforeEach(() => { + nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + }); + nonInteractiveContext.session.stats.sessionStartTime = startTime; + }); + + it('should return text stats without calling addItem', async () => { + if (!statsCommand.action) throw new Error('Command has no action'); + + const result = (await statsCommand.action(nonInteractiveContext, '')) as { + type: string; + messageType: string; + content: string; + }; + + expect(result.type).toBe('message'); + expect(result.messageType).toBe('info'); + expect(result.content).toContain('Session duration'); + expect(result.content).toContain('Prompts'); + expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled(); + }); + + it('should return error if sessionStartTime is not available', async () => { + if (!statsCommand.action) throw new Error('Command has no action'); + + nonInteractiveContext.session.stats.sessionStartTime = undefined; + + const result = (await statsCommand.action(nonInteractiveContext, '')) as { + type: string; + messageType: string; + }; + + expect(result.type).toBe('message'); + expect(result.messageType).toBe('error'); + }); + + it('stats model subcommand should return text in non-interactive mode', async () => { + const modelSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'model', + ); + if (!modelSubCommand?.action) throw new Error('Subcommand has no action'); + + const result = (await modelSubCommand.action( + nonInteractiveContext, + '', + )) as { type: string; content: string }; + + expect(result.type).toBe('message'); + expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled(); + }); + + it('stats tools subcommand should return text in non-interactive mode', async () => { + const toolsSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'tools', + ); + if (!toolsSubCommand?.action) throw new Error('Subcommand has no action'); + + const result = (await toolsSubCommand.action( + nonInteractiveContext, + '', + )) as { type: string; content: string }; + + expect(result.type).toBe('message'); + expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index 0ee487ecc..6d96bccf9 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -10,6 +10,7 @@ import { formatDuration } from '../utils/formatters.js'; import { type CommandContext, type SlashCommand, + type MessageActionReturn, CommandKind, } from './types.js'; import { t } from '../../i18n/index.js'; @@ -21,11 +22,20 @@ export const statsCommand: SlashCommand = { return t('check session stats. Usage: /stats [model|tools]'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', - action: (context: CommandContext) => { + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: (context: CommandContext): MessageActionReturn | void => { const now = new Date(); const { sessionStartTime } = context.session.stats; if (!sessionStartTime) { + if (context.executionMode !== 'interactive') { + return { + type: 'message', + messageType: 'error', + content: t( + 'Session start time is unavailable, cannot calculate stats.', + ), + }; + } context.ui.addItem( { type: MessageType.ERROR, @@ -37,6 +47,30 @@ export const statsCommand: SlashCommand = { } const wallDuration = now.getTime() - sessionStartTime.getTime(); + if (context.executionMode !== 'interactive') { + const { promptCount, metrics } = context.session.stats; + let totalPromptTokens = 0; + let totalCandidateTokens = 0; + let totalRequests = 0; + for (const modelMetrics of Object.values(metrics.models)) { + totalPromptTokens += modelMetrics.tokens.prompt; + totalCandidateTokens += modelMetrics.tokens.candidates; + totalRequests += modelMetrics.api.totalRequests; + } + return { + type: 'message', + messageType: 'info', + content: [ + `Session duration: ${formatDuration(wallDuration)}`, + `Prompts: ${promptCount}`, + `API requests: ${totalRequests}`, + `Tokens — prompt: ${totalPromptTokens}, output: ${totalCandidateTokens}`, + `Tool calls: ${metrics.tools.totalCalls} (${metrics.tools.totalSuccess} ok, ${metrics.tools.totalFail} fail)`, + `Files: +${metrics.files.totalLinesAdded} / -${metrics.files.totalLinesRemoved} lines`, + ].join('\n'), + }; + } + const statsItem: HistoryItemStats = { type: MessageType.STATS, duration: formatDuration(wallDuration), @@ -51,8 +85,27 @@ export const statsCommand: SlashCommand = { return t('Show model-specific usage statistics.'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', - action: (context: CommandContext) => { + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: (context: CommandContext): MessageActionReturn | void => { + if (context.executionMode !== 'interactive') { + const { metrics } = context.session.stats; + const lines: string[] = []; + for (const [modelName, modelMetrics] of Object.entries( + metrics.models, + )) { + lines.push( + `${modelName}: prompt=${modelMetrics.tokens.prompt}, output=${modelMetrics.tokens.candidates}, cached=${modelMetrics.tokens.cached}`, + ); + } + if (lines.length === 0) { + lines.push('No model usage data yet.'); + } + return { + type: 'message', + messageType: 'info', + content: lines.join('\n'), + }; + } context.ui.addItem( { type: MessageType.MODEL_STATS, @@ -67,8 +120,21 @@ export const statsCommand: SlashCommand = { return t('Show tool-specific usage statistics.'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', - action: (context: CommandContext) => { + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: (context: CommandContext): MessageActionReturn | void => { + if (context.executionMode !== 'interactive') { + const { metrics } = context.session.stats; + const { tools } = metrics; + const toolNames = Object.keys(tools.byName); + const content = + toolNames.length > 0 + ? [ + `Tool calls: ${tools.totalCalls} total (${tools.totalSuccess} ok, ${tools.totalFail} fail)`, + ...toolNames.map((name) => ` ${name}`), + ].join('\n') + : 'No tool usage data yet.'; + return { type: 'message', messageType: 'info', content }; + } context.ui.addItem( { type: MessageType.TOOL_STATS, diff --git a/packages/cli/src/ui/commands/statuslineCommand.ts b/packages/cli/src/ui/commands/statuslineCommand.ts index 759503e46..7e2a1fdeb 100644 --- a/packages/cli/src/ui/commands/statuslineCommand.ts +++ b/packages/cli/src/ui/commands/statuslineCommand.ts @@ -14,7 +14,7 @@ export const statuslineCommand: SlashCommand = { return t("Set up Qwen Code's status line UI"); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, 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 eaf011d7e..3d0c53b42 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -23,7 +23,6 @@ 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; diff --git a/packages/cli/src/ui/commands/terminalSetupCommand.ts b/packages/cli/src/ui/commands/terminalSetupCommand.ts index 22888fd90..2f25f3495 100644 --- a/packages/cli/src/ui/commands/terminalSetupCommand.ts +++ b/packages/cli/src/ui/commands/terminalSetupCommand.ts @@ -23,7 +23,7 @@ export const terminalSetupCommand: SlashCommand = { ); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (): Promise => { try { diff --git a/packages/cli/src/ui/commands/themeCommand.ts b/packages/cli/src/ui/commands/themeCommand.ts index 5761ee13e..b73531057 100644 --- a/packages/cli/src/ui/commands/themeCommand.ts +++ b/packages/cli/src/ui/commands/themeCommand.ts @@ -14,7 +14,7 @@ export const themeCommand: SlashCommand = { return t('change the theme'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, 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 49123623f..2ea09d060 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -18,7 +18,7 @@ export const toolsCommand: SlashCommand = { return t('list available Qwen Code tools. Usage: /tools [desc]'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, 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 5a213cc2b..c47ee7148 100644 --- a/packages/cli/src/ui/commands/trustCommand.ts +++ b/packages/cli/src/ui/commands/trustCommand.ts @@ -14,7 +14,7 @@ export const trustCommand: SlashCommand = { return t('Manage folder trust settings'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, 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 ee7a1c096..01e6d6564 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -274,23 +274,6 @@ export type CommandSource = // | '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; @@ -329,17 +312,11 @@ export interface SlashCommand { */ 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. + * Explicit declaration is always authoritative. If omitted, the system falls + * back to a conservative default based on CommandKind. * See getEffectiveSupportedModes() in commandUtils.ts for the full logic. */ supportedModes?: ExecutionMode[]; diff --git a/packages/cli/src/ui/commands/vimCommand.ts b/packages/cli/src/ui/commands/vimCommand.ts index 28e30806c..0999357ae 100644 --- a/packages/cli/src/ui/commands/vimCommand.ts +++ b/packages/cli/src/ui/commands/vimCommand.ts @@ -14,7 +14,7 @@ export const vimCommand: SlashCommand = { return t('toggle vim mode on/off'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (context, _args) => { const newVimState = await context.ui.toggleVimEnabled(); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 71ce5c420..018c7d4b5 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -190,6 +190,13 @@ export const InputPrompt: React.FC = ({ !justNavigatedHistory, ); + // Ref so renderLineWithHighlighting (stable useCallback) can access fresh ghost text + const midInputGhostTextRef = useRef<{ + text: string; + insertPosition: number; + } | null>(null); + midInputGhostTextRef.current = completion.midInputGhostText; + const reverseSearchCompletion = useReverseSearchCompletion( buffer, shellHistoryData, @@ -803,6 +810,18 @@ export const InputPrompt: React.FC = ({ } } + // Accept mid-input ghost text with Tab (when no dropdown is visible) + if ( + key.name === 'tab' && + !key.paste && + !key.shift && + !completion.showSuggestions && + midInputGhostTextRef.current + ) { + buffer.insert(midInputGhostTextRef.current.text); + return true; + } + // Attachment mode handling - process before history navigation if (isAttachmentMode && attachments.length > 0) { if (key.name === 'left') { @@ -1136,12 +1155,31 @@ export const InputPrompt: React.FC = ({ }); if (isOnCursorLine && cursorVisualColAbsolute === cpLen(lineText)) { - // Add zero-width space after cursor to prevent Ink from trimming trailing whitespace - renderedLine.push( - - {showCursorOpt ? chalk.inverse(' ') + '\u200B' : ' \u200B'} - , - ); + // Check for mid-input ghost text (only renders when cursor is at end of input) + const ghostText = midInputGhostTextRef.current; + if (ghostText && showCursorOpt && ghostText.text.length > 0) { + // First ghost char: inverted (as cursor). Rest: dimmed gray. + const firstChar = ghostText.text[0]!; + const rest = ghostText.text.slice(firstChar.length); + renderedLine.push( + {chalk.inverse(firstChar)}, + ); + if (rest.length > 0) { + renderedLine.push( + + {rest} + , + ); + } + renderedLine.push({`\u200B`}); + } else { + // Add zero-width space after cursor to prevent Ink from trimming trailing whitespace + renderedLine.push( + + {showCursorOpt ? chalk.inverse(' ') + '\u200B' : ' \u200B'} + , + ); + } } return {renderedLine}; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 5d9cb0ba0..135ec7d11 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -44,6 +44,7 @@ import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; import { BundledSkillLoader } from '../../services/BundledSkillLoader.js'; import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; +import { SkillCommandLoader } from '../../services/SkillCommandLoader.js'; import { parseSlashCommand } from '../../utils/commands.js'; import { isBtwCommand } from '../utils/commandUtils.js'; import { clearScreen } from '../../utils/stdioHelpers.js'; @@ -262,7 +263,6 @@ export const useSlashCommandProcessor = ( ); const commandContext = useMemo( (): CommandContext => ({ - executionMode: 'interactive', services: { config, settings, @@ -301,6 +301,7 @@ export const useSlashCommandProcessor = ( sessionShellAllowlist, startNewSession, }, + executionMode: 'interactive' as const, }), [ config, @@ -359,6 +360,7 @@ export const useSlashCommandProcessor = ( new McpPromptLoader(config), new BuiltinCommandLoader(config), new BundledSkillLoader(config), + new SkillCommandLoader(config), new FileCommandLoader(config), ]; const disabled = config?.getDisabledSlashCommands() ?? []; @@ -367,6 +369,53 @@ export const useSlashCommandProcessor = ( controller.signal, disabled.length > 0 ? new Set(disabled) : undefined, ); + // Register model-invocable commands provider so SkillTool can include + // bundled skills, file commands, and MCP prompts in its description. + if (config) { + config.setModelInvocableCommandsProvider(() => + commandService.getModelInvocableCommands().map((cmd) => ({ + name: cmd.name, + description: + typeof cmd.description === 'string' + ? cmd.description + : cmd.description, + })), + ); + // Register executor so SkillTool can actually invoke model-invocable + // commands (e.g. MCP prompts) that are not file-based skills. + config.setModelInvocableCommandsExecutor( + async (name: string, args: string = '') => { + const commands = commandService.getModelInvocableCommands(); + const cmd = commands.find((c) => c.name === name); + if (!cmd?.action) return null; + // Build a minimal context; submit_prompt actions only need + // invocation + services.config, not UI state. + const minimalContext = { + executionMode: 'non_interactive' as const, + invocation: { + raw: args ? `/${name} ${args}` : `/${name}`, + name, + args, + }, + services: { config, settings, git: gitService, logger: null }, + } as unknown as Parameters[0]; + const result = await cmd.action(minimalContext, args); + if (!result || result.type !== 'submit_prompt') return null; + const content = result.content; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .map((p) => + typeof p === 'string' + ? p + : ((p as { text?: string }).text ?? ''), + ) + .join(''); + } + return null; + }, + ); + } // Avoid overwriting newer results from a subsequent effect run if (!controller.signal.aborted) { setCommands(commandService.getCommandsForMode('interactive')); @@ -381,7 +430,7 @@ export const useSlashCommandProcessor = ( return () => { controller.abort(); }; - }, [config, reloadTrigger, isConfigInitialized]); + }, [config, reloadTrigger, isConfigInitialized, settings, gitService]); const handleSlashCommand = useCallback( async ( diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index 8cb273c98..cedea5c56 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -9,7 +9,11 @@ import type { Suggestion } from '../components/SuggestionsDisplay.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { TextBuffer } from '../components/shared/text-buffer.js'; import { logicalPosToOffset } from '../components/shared/text-buffer.js'; -import { isSlashCommand } from '../utils/commandUtils.js'; +import { + isSlashCommand, + findMidInputSlashCommand, + getBestSlashCommandMatch, +} from '../utils/commandUtils.js'; import { toCodePoints } from '../utils/textUtils.js'; import { useAtCompletion } from './useAtCompletion.js'; import { useSlashCompletion } from './useSlashCompletion.js'; @@ -35,6 +39,8 @@ export interface UseCommandCompletionReturn { navigateUp: () => void; navigateDown: () => void; handleAutocomplete: (indexToUse: number) => void; + /** Inline ghost text for mid-input slash commands (not at line start). */ + midInputGhostText: { text: string; insertPosition: number } | null; } export function useCommandCompletion( @@ -186,8 +192,12 @@ export function useCommandCompletion( let start = completionStart; let end = completionEnd; if (completionMode === CompletionMode.SLASH) { - start = slashCompletionRange.completionStart; - end = slashCompletionRange.completionEnd; + // slashCompletionRange positions are relative to the query string. + // completionStart is the line-column offset where the query begins + // (0 for line-start slash commands, tokenStart for mid-input tokens). + const lineOffset = completionStart; + start = lineOffset + slashCompletionRange.completionStart; + end = lineOffset + slashCompletionRange.completionEnd; } if (start === -1 || end === -1) { @@ -228,6 +238,32 @@ export function useCommandCompletion( ], ); + // Inline ghost text for mid-input slash commands (not at line start). + // Computed synchronously via useMemo to avoid one-frame flicker. + const midInputGhostText = useMemo((): { + text: string; + insertPosition: number; + } | null => { + if (!active || reverseSearchActive) return null; + const cursorOffset = logicalPosToOffset(buffer.lines, cursorRow, cursorCol); + const midCmd = findMidInputSlashCommand(buffer.text, cursorOffset); + if (!midCmd) return null; + const match = getBestSlashCommandMatch( + midCmd.partialCommand, + slashCommands, + ); + if (!match) return null; + return { text: match.suffix, insertPosition: cursorOffset }; + }, [ + buffer.text, + buffer.lines, + cursorRow, + cursorCol, + slashCommands, + active, + reverseSearchActive, + ]); + return { suggestions, activeSuggestionIndex, @@ -241,5 +277,6 @@ export function useCommandCompletion( navigateUp, navigateDown, handleAutocomplete, + midInputGhostText, }; } diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 766a72ed3..4273bc1ea 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -14,6 +14,7 @@ import { copyToClipboard, getUrlOpenCommand, CodePage, + findMidInputSlashCommand, } from './commandUtils.js'; // Mock child_process @@ -487,3 +488,51 @@ describe('commandUtils', () => { }); }); }); + +describe('findMidInputSlashCommand', () => { + it('returns null when input starts with / (handled by start-of-line completion)', () => { + expect(findMidInputSlashCommand('/review', 7)).toBeNull(); + }); + + it('returns null when cursor is before the slash token', () => { + // "hello /review", cursor at position 3 (inside "hello") + expect(findMidInputSlashCommand('hello /review', 3)).toBeNull(); + }); + + it('returns match when cursor is exactly at the end of the token', () => { + // "hello /re", cursor at end (offset=9) + const result = findMidInputSlashCommand('hello /re', 9); + expect(result).toEqual({ + token: '/re', + startPos: 6, + partialCommand: 're', + }); + }); + + it('returns null when cursor is inside the token (not at the end)', () => { + // "hello /review", cursor at offset 9 (inside 'review') + // slashPos=6, fullCommand="review"(len=6), end=13 → 9 !== 13 → null + expect(findMidInputSlashCommand('hello /review', 9)).toBeNull(); + }); + + it('returns null when cursor has moved past the token into a space', () => { + // "hello /review ", cursor at offset 14 (after the trailing space) + expect(findMidInputSlashCommand('hello /review ', 14)).toBeNull(); + }); + + it('returns match for empty partial (cursor immediately after /)', () => { + // partialCommand="" → getBestSlashCommandMatch will return null, but + // findMidInputSlashCommand itself should return the match object + const result = findMidInputSlashCommand('hello /', 7); + expect(result).toEqual({ + token: '/', + startPos: 6, + partialCommand: '', + }); + }); + + it('returns null when / is not preceded by whitespace', () => { + // "hello/review", no space before slash + expect(findMidInputSlashCommand('hello/review', 12)).toBeNull(); + }); +}); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 9436447f7..18f74015c 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -7,6 +7,7 @@ import type { SpawnOptions } from 'node:child_process'; import { spawn } from 'node:child_process'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import type { SlashCommand } from '../commands/types.js'; /** * Common Windows console code pages (CP) used for encoding conversions. @@ -183,3 +184,83 @@ export const getUrlOpenCommand = (): string => { } return openCmd; }; + +/** + * Represents a slash command token found mid-input (not at position 0). + * e.g., in "hello /st", startPos=6, partialCommand="st" + */ +export type MidInputSlashCommand = { + /** Full token including slash, e.g. "/st" */ + token: string; + /** Position of the "/" in the full input string */ + startPos: number; + /** Command portion without slash, e.g. "st" */ + partialCommand: string; +}; + +/** + * Finds a slash command token that appears mid-input (not at position 0). + * Only triggers when the "/" is preceded by whitespace and the cursor is + * right at or within the partial command (no text between cursor and slash). + * + * Returns null when input starts with "/" (handled by start-of-line completion). + */ +export function findMidInputSlashCommand( + input: string, + cursorOffset: number, +): MidInputSlashCommand | null { + // Start-of-line slash handled by existing dropdown completion + if (input.startsWith('/')) return null; + + const beforeCursor = input.slice(0, cursorOffset); + + // Match: whitespace then "/" then optional command chars, anchored at end + // Capture whitespace instead of lookbehind to avoid JSC JIT regression + const match = beforeCursor.match(/\s\/([a-zA-Z0-9_:-]*)$/); + if (!match || match.index === undefined) return null; + + const slashPos = match.index + 1; // +1 to skip the captured whitespace char + const textAfterSlash = input.slice(slashPos + 1); + + // Extend to next space (or end of input) to find the full command name + const commandMatch = textAfterSlash.match(/^[a-zA-Z0-9_:-]*/); + const fullCommand = commandMatch ? commandMatch[0] : ''; + + // Only show ghost text when cursor is exactly at the end of the token. + // If the cursor is inside the token or past it, return null. + if (cursorOffset !== slashPos + 1 + fullCommand.length) return null; + + return { + token: '/' + fullCommand, + startPos: slashPos, + partialCommand: input.slice(slashPos + 1, cursorOffset), + }; +} + +/** + * Finds the best (alphabetically first) prefix-matching command for a partial + * command string. Returns the completion suffix and full command name, or null. + * + * e.g. partialCommand="st" → { suffix: "ats", fullCommand: "stats" } + */ +export function getBestSlashCommandMatch( + partialCommand: string, + commands: readonly SlashCommand[], +): { suffix: string; fullCommand: string } | null { + if (!partialCommand) return null; + const query = partialCommand.toLowerCase(); + let best: { suffix: string; fullCommand: string } | null = null; + for (const cmd of commands) { + // Only suggest model-invocable commands for mid-input completion, + // since built-in commands typed in the middle of text won't be executed. + if (!cmd.modelInvocable) continue; + const name = cmd.name.toLowerCase(); + if (name.startsWith(query) && name !== query) { + const suffix = cmd.name.slice(partialCommand.length); + if (!best || cmd.name < best.fullCommand) { + best = { suffix, fullCommand: cmd.name }; + } + } + } + return best; +} diff --git a/packages/cli/src/utils/nonInteractiveHelpers.test.ts b/packages/cli/src/utils/nonInteractiveHelpers.test.ts index 4e2273256..0a6015a85 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.test.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.test.ts @@ -36,50 +36,44 @@ import { } from './nonInteractiveHelpers.js'; // Mock dependencies -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'], - }, - ]; +vi.mock('../nonInteractiveCliCommands.js', () => ({ + getAvailableCommands: vi + .fn() + .mockImplementation( + async (_config: unknown, _signal: AbortSignal, mode: string = 'acp') => { + const allCommands = [ + { + name: 'help', + supportedModes: ['interactive'] as const, + }, + { + name: 'commit', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + }, + { + name: 'memory', + supportedModes: ['interactive'] as const, + }, + { + name: 'init', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + }, + { + name: 'summary', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + }, + { + name: 'compress', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + }, + ] as const; - return filterCommandsForMode( - allCommands as unknown as Parameters< - typeof filterCommandsForMode - >[0], - mode as Parameters[1], - ); - }, - ), - }; -}); + return allCommands.filter((cmd) => + (cmd.supportedModes as readonly string[]).includes(mode), + ); + }, + ), +})); vi.mock('../ui/utils/computeStats.js', () => ({ computeSessionStats: vi.fn().mockReturnValue({ diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ed548562b..16a9f05f7 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -558,6 +558,12 @@ export class Config { private extensionManager!: ExtensionManager; private skillManager: SkillManager | null = null; private permissionManager: PermissionManager | null = null; + private modelInvocableCommandsProvider: + | (() => ReadonlyArray<{ name: string; description: string }>) + | null = null; + private modelInvocableCommandsExecutor: + | ((name: string, args?: string) => Promise) + | null = null; private fileSystemService: FileSystemService; private contentGeneratorConfig!: ContentGeneratorConfig; private contentGeneratorConfigSources: ContentGeneratorConfigSources = {}; @@ -1705,22 +1711,19 @@ export class Config { return merged; } + getToolDiscoveryCommand(): string | undefined { + return this.toolDiscoveryCommand; + } + /** * Returns the pre-merged list of slash command names that should be hidden * from the CLI surface. Callers should treat this as a case-insensitive * denylist; `CommandService.create` handles the normalization. - * - * CLI callers (loadCliConfig) populate this from settings, the - * `--disabled-slash-commands` flag, and `QWEN_DISABLED_SLASH_COMMANDS`. */ getDisabledSlashCommands(): readonly string[] { return this.disabledSlashCommands; } - getToolDiscoveryCommand(): string | undefined { - return this.toolDiscoveryCommand; - } - getToolCallCommand(): string | undefined { return this.toolCallCommand; } @@ -2502,6 +2505,49 @@ export class Config { return this.skillManager; } + /** + * Registers a provider that returns model-invocable commands (e.g., bundled + * skills, user/project file commands, MCP prompts). Called by the CLI's + * CommandService after initialisation so that SkillTool can merge these into + * its tool description. + */ + setModelInvocableCommandsProvider( + provider: () => ReadonlyArray<{ name: string; description: string }>, + ): void { + this.modelInvocableCommandsProvider = provider; + } + + /** + * Returns the registered model-invocable commands provider, or null if none + * has been registered (e.g., in SDK mode). + */ + getModelInvocableCommandsProvider(): + | (() => ReadonlyArray<{ name: string; description: string }>) + | null { + return this.modelInvocableCommandsProvider; + } + + /** + * Registers an executor that can invoke a model-invocable command by name + * (e.g., MCP prompts). Returns the prompt content as a string, or null if + * the command cannot be found or executed. Called by the CLI layer. + */ + setModelInvocableCommandsExecutor( + executor: (name: string, args?: string) => Promise, + ): void { + this.modelInvocableCommandsExecutor = executor; + } + + /** + * Returns the registered model-invocable commands executor, or null if none + * has been registered (e.g., in SDK mode). + */ + getModelInvocableCommandsExecutor(): + | ((name: string, args?: string) => Promise) + | null { + return this.modelInvocableCommandsExecutor; + } + getPermissionManager(): PermissionManager | null { return this.permissionManager; } diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index f0838fa45..0c92e0761 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -450,6 +450,18 @@ export class SkillManager { // Extract optional model field const model = parseModelField(frontmatter); + // Extract when_to_use and disable-model-invocation + const whenToUse = + typeof frontmatter['when_to_use'] === 'string' + ? frontmatter['when_to_use'] + : undefined; + const disableModelInvocationRaw = frontmatter['disable-model-invocation']; + const disableModelInvocation = + disableModelInvocationRaw === true || + disableModelInvocationRaw === 'true' + ? true + : undefined; + const config: SkillConfig = { name, description, @@ -460,6 +472,8 @@ export class SkillManager { level, filePath, body: body.trim(), + whenToUse, + disableModelInvocation, }; // Validate the parsed configuration @@ -649,7 +663,7 @@ export class SkillManager { const skills: SkillConfig[] = []; for (const extension of extensions) { extension.skills?.forEach((skill) => { - skills.push(skill); + skills.push({ ...skill, extensionName: extension.name }); }); } debugLogger.debug( diff --git a/packages/core/src/skills/types.ts b/packages/core/src/skills/types.ts index c7afcf3ff..fe7332466 100644 --- a/packages/core/src/skills/types.ts +++ b/packages/core/src/skills/types.ts @@ -80,6 +80,21 @@ export interface SkillConfig { * For extension-level skills: the name of the providing extension */ extensionName?: string; + + /** + * Describes when to invoke this skill — shown to the model in the SkillTool + * description so it can decide whether to use it. Parsed from the + * `when_to_use` frontmatter field in SKILL.md. + */ + whenToUse?: string; + + /** + * When true, the skill is hidden from the model's SkillTool listing and + * cannot be invoked by the model. Only the user can trigger it via + * `/`. Parsed from the `disable-model-invocation` frontmatter + * field in SKILL.md. + */ + disableModelInvocation?: boolean; } /** diff --git a/packages/core/src/tools/skill.test.ts b/packages/core/src/tools/skill.test.ts index 821d407b9..a6bdfb0d4 100644 --- a/packages/core/src/tools/skill.test.ts +++ b/packages/core/src/tools/skill.test.ts @@ -76,6 +76,8 @@ describe('SkillTool', () => { getSessionId: vi.fn().mockReturnValue('test-session-id'), getSkillManager: vi.fn(), getGeminiClient: vi.fn().mockReturnValue(undefined), + getModelInvocableCommandsProvider: vi.fn().mockReturnValue(null), + getModelInvocableCommandsExecutor: vi.fn().mockReturnValue(null), } as unknown as Config; changeListeners = []; @@ -434,6 +436,162 @@ describe('SkillTool', () => { }); }); + describe('modelInvocableCommands integration', () => { + const mockCommands = [ + { name: 'review', description: 'Bundled code review skill' }, + { name: 'mcp-prompt-a', description: 'An MCP prompt' }, + ]; + + it('should show non-skill commands in section', async () => { + // 'review' and 'mcp-prompt-a' don't overlap with file skills + vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue( + () => mockCommands, + ); + + const tool = new SkillTool(config); + await vi.runAllTimersAsync(); + + expect(tool.description).not.toContain(''); + expect(tool.description).toContain(''); + expect(tool.description).toContain('review'); + expect(tool.description).toContain('mcp-prompt-a'); + }); + + it('should not duplicate commands already present as file-based skills', async () => { + // 'code-review' matches a skill in mockSkills → should be filtered out + const commandsIncludingSkill = [ + { name: 'code-review', description: 'Bundled version of code-review' }, + { name: 'mcp-prompt-a', description: 'An MCP prompt' }, + ]; + vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue( + () => commandsIncludingSkill, + ); + + const tool = new SkillTool(config); + await vi.runAllTimersAsync(); + + // 'code-review' is already in as a file skill, must NOT appear twice + const codeReviewMatches = (tool.description.match(/code-review/g) || []) + .length; + expect(codeReviewMatches).toBe(1); + // 'mcp-prompt-a' is not a file-based skill, must appear in the unified list + expect(tool.description).toContain('mcp-prompt-a'); + }); + + it('should hide when all commands are already covered by skills', async () => { + // Both command names match existing skills + const commandsAllOverlapping = [ + { name: 'code-review', description: 'Bundled code-review' }, + { name: 'testing', description: 'Bundled testing' }, + ]; + vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue( + () => commandsAllOverlapping, + ); + + const tool = new SkillTool(config); + await vi.runAllTimersAsync(); + + expect(tool.description).not.toContain(''); + // All commands overlapped with file skills, so no extra entries added + expect(tool.description).toContain(''); + }); + }); + + describe('validateToolParams with modelInvocableCommands', () => { + beforeEach(async () => { + vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue( + () => [{ name: 'mcp-prompt-a', description: 'An MCP prompt' }], + ); + await skillTool.refreshSkills(); + }); + + it('should accept a model-invocable command name that is not a file skill', () => { + const result = skillTool.validateToolParams({ skill: 'mcp-prompt-a' }); + expect(result).toBeNull(); + }); + + it('should reject a name not in skills or commands, listing both in error', () => { + const result = skillTool.validateToolParams({ skill: 'unknown' }); + expect(result).toContain('"unknown" not found'); + expect(result).toContain('code-review'); + expect(result).toContain('mcp-prompt-a'); + }); + }); + + describe('commandExecutor fallback in execute()', () => { + beforeEach(async () => { + // Expose an MCP-only command that has no file-based skill + vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue( + () => [{ name: 'mcp-prompt-a', description: 'An MCP prompt' }], + ); + await skillTool.refreshSkills(); + }); + + it('should invoke commandExecutor when loadSkillForRuntime returns null', async () => { + const executor = vi.fn().mockResolvedValue('Prompt content from MCP'); + vi.mocked(config.getModelInvocableCommandsExecutor).mockReturnValue( + executor, + ); + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(null); + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation({ skill: 'mcp-prompt-a' }); + const result = await invocation.execute(); + + expect(executor).toHaveBeenCalledWith('mcp-prompt-a'); + const llmText = partToString(result.llmContent); + expect(llmText).toBe('Prompt content from MCP'); + expect(result.returnDisplay).toBe('Executed command: mcp-prompt-a'); + }); + + it('should fall through to not-found error when executor returns null', async () => { + const executor = vi.fn().mockResolvedValue(null); + vi.mocked(config.getModelInvocableCommandsExecutor).mockReturnValue( + executor, + ); + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(null); + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation({ skill: 'mcp-prompt-a' }); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).toContain('"mcp-prompt-a" not found'); + }); + + it('should skip commandExecutor when no executor is registered', async () => { + vi.mocked(config.getModelInvocableCommandsExecutor).mockReturnValue(null); + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(null); + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation({ skill: 'mcp-prompt-a' }); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).toContain('"mcp-prompt-a" not found'); + }); + + it('should use loadSkillForRuntime first and skip executor when skill is found', async () => { + const executor = vi.fn().mockResolvedValue('Should not be called'); + vi.mocked(config.getModelInvocableCommandsExecutor).mockReturnValue( + executor, + ); + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue( + mockSkills[0], + ); + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation({ skill: 'code-review' }); + await invocation.execute(); + + expect(executor).not.toHaveBeenCalled(); + }); + }); + describe('modelOverride propagation', () => { it('should propagate model from skill config to ToolResult', async () => { const skillWithModel: SkillConfig = { diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts index c4e90ea76..f2a7a0c7f 100644 --- a/packages/core/src/tools/skill.ts +++ b/packages/core/src/tools/skill.ts @@ -35,6 +35,10 @@ export class SkillTool extends BaseDeclarativeTool { private skillManager: SkillManager; private availableSkills: SkillConfig[] = []; + private modelInvocableCommands: ReadonlyArray<{ + name: string; + description: string; + }> = []; private loadedSkillNames: Set = new Set(); constructor(private readonly config: Config) { @@ -81,11 +85,23 @@ export class SkillTool extends BaseDeclarativeTool { */ async refreshSkills(): Promise { try { - this.availableSkills = await this.skillManager.listSkills(); + this.availableSkills = (await this.skillManager.listSkills()).filter( + (s) => !s.disableModelInvocation, + ); + // Merge in model-invocable commands from CommandService (injected via Config), + // but exclude any whose names already appear as file-based skills to avoid + // showing the same skill in both and . + const provider = this.config.getModelInvocableCommandsProvider(); + const allCommands = provider ? provider() : []; + const skillNames = new Set(this.availableSkills.map((s) => s.name)); + this.modelInvocableCommands = allCommands.filter( + (cmd) => !skillNames.has(cmd.name), + ); this.updateDescriptionAndSchema(); } catch (error) { debugLogger.warn('Failed to load skills for Skills tool:', error); this.availableSkills = []; + this.modelInvocableCommands = []; this.updateDescriptionAndSchema(); } finally { // Update the client with the new tools @@ -97,29 +113,45 @@ export class SkillTool extends BaseDeclarativeTool { } /** - * Updates the tool's description and schema based on available skills. + * Updates the tool's description and schema based on available skills and + * model-invocable commands (e.g. bundled skills, file commands, MCP prompts). */ private updateDescriptionAndSchema(): void { - let skillDescriptions = ''; - if (this.availableSkills.length === 0) { - skillDescriptions = - 'No skills are currently configured. Skills can be created by adding directories with SKILL.md files to .qwen/skills/ or ~/.qwen/skills/.'; - } else { - skillDescriptions = this.availableSkills - .map( - (skill) => ` + // Merge file-based skills and prompt commands into a single unified list, + // matching Claude Code's design where all invocable commands are listed together. + const allSkillEntries: string[] = []; + + for (const skill of this.availableSkills) { + allSkillEntries.push(` ${skill.name} -${skill.description} (${skill.level}) +${skill.description}${skill.whenToUse ? ` — ${skill.whenToUse}` : ''} (${skill.level}) ${skill.level} -`, - ) - .join('\n'); +`); + } + + for (const cmd of this.modelInvocableCommands) { + allSkillEntries.push(` + +${cmd.name} + + +${cmd.description} + +`); + } + + let skillDescriptions = ''; + if (allSkillEntries.length === 0) { + skillDescriptions = + 'No skills are currently configured. Skills can be created by adding directories with SKILL.md files to .qwen/skills/ or ~/.qwen/skills/.'; + } else { + skillDescriptions = allSkillEntries.join('\n'); } const baseDescription = `Execute a skill within the main conversation @@ -149,8 +181,7 @@ Important: ${skillDescriptions} - -`; +`; // Update description using object property assignment (this as { description: string }).description = baseDescription; } @@ -165,20 +196,26 @@ ${skillDescriptions} return 'Parameter "skill" must be a non-empty string.'; } - // Validate that the skill exists + // Check file-based skills const skillExists = this.availableSkills.some( (skill) => skill.name === params.skill, ); + if (skillExists) return null; - if (!skillExists) { - const availableNames = this.availableSkills.map((s) => s.name); - if (availableNames.length === 0) { - return `Skill "${params.skill}" not found. No skills are currently available.`; - } - return `Skill "${params.skill}" not found. Available skills: ${availableNames.join(', ')}`; + // Check model-invocable commands (e.g. MCP prompts) listed in the description + const commandExists = this.modelInvocableCommands.some( + (cmd) => cmd.name === params.skill, + ); + if (commandExists) return null; + + const availableNames = [ + ...this.availableSkills.map((s) => s.name), + ...this.modelInvocableCommands.map((c) => c.name), + ]; + if (availableNames.length === 0) { + return `Skill "${params.skill}" not found. No skills are currently available.`; } - - return null; + return `Skill "${params.skill}" not found. Available skills: ${availableNames.join(', ')}`; } protected createInvocation(params: SkillParams) { @@ -187,6 +224,7 @@ ${skillDescriptions} this.skillManager, params, (name: string) => this.loadedSkillNames.add(name), + this.config.getModelInvocableCommandsExecutor(), ); } @@ -218,6 +256,9 @@ class SkillToolInvocation extends BaseToolInvocation { private readonly skillManager: SkillManager, params: SkillParams, private readonly onSkillLoaded: (name: string) => void, + private readonly commandExecutor: + | ((name: string, args?: string) => Promise) + | null = null, ) { super(params); } @@ -237,6 +278,22 @@ class SkillToolInvocation extends BaseToolInvocation { ); if (!skill) { + // Try model-invocable command executor (e.g. MCP prompts) + if (this.commandExecutor) { + const content = await this.commandExecutor(this.params.skill); + if (content !== null) { + logSkillLaunch( + this.config, + new SkillLaunchEvent(this.params.skill, true), + ); + this.onSkillLoaded(this.params.skill); + return { + llmContent: [{ text: content }], + returnDisplay: `Executed command: ${this.params.skill}`, + }; + } + } + // Log failed skill launch logSkillLaunch( this.config, diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 3abba87df..a54ddd1a4 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -463,6 +463,19 @@ } } }, + "slashCommands": { + "description": "Configuration for slash commands exposed by the CLI. Useful for locking down the command surface in multi-tenant or enterprise deployments.", + "type": "object", + "properties": { + "disabled": { + "description": "Slash command names to hide and refuse to execute. Matched case-insensitively against the final command name (for extension commands this is the disambiguated form, e.g. \"myext.deploy\"). Merged as a union across settings scopes, so workspace settings can add to but not remove entries defined in system/user settings.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "permissions": { "description": "Permission rules controlling tool usage. Rules are evaluated in priority order: deny > ask > allow.", "type": "object", @@ -490,19 +503,6 @@ } } }, - "slashCommands": { - "description": "Configuration for slash commands exposed by the CLI. Useful for locking down the command surface in multi-tenant or enterprise deployments.", - "type": "object", - "properties": { - "disabled": { - "description": "Slash command names to hide and refuse to execute. Matched case-insensitively against the final command name (for extension commands this is the disambiguated form, e.g. \"myext.deploy\"). Merged as a union across settings scopes, so workspace settings can add to but not remove entries defined in system/user settings.", - "type": "array", - "items": { - "type": "string" - } - } - } - }, "tools": { "description": "Settings for built-in and custom tools.", "type": "object",