From 2710bdec0df5d3011f0024ec2d4b2b81bfd26ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A1=BE=E7=9B=BC?= Date: Wed, 22 Apr 2026 19:12:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20Phase=202=20=E2=80=94=20slash=20co?= =?UTF-8?q?mmand=20multi-mode=20expansion,=20ACP=20fixes,=20and=20UX=20imp?= =?UTF-8?q?rovements=20(#3377)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(cli): replace slash command whitelist with capability-based filtering (Phase 1) ## Summary Replace the hardcoded ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE whitelist with a unified, capability-based command metadata model. This is Phase 1 of the slash command architecture refactor described in docs/design/slash-command/. ## Key changes ### New types (types.ts) - Add ExecutionMode ('interactive' | 'non_interactive' | 'acp') - Add CommandSource ('builtin-command' | 'bundled-skill' | 'skill-dir-command' | 'plugin-command' | 'mcp-prompt') - Add CommandType ('prompt' | 'local' | 'local-jsx') - Extend SlashCommand interface with: source, sourceLabel, commandType, supportedModes, userInvocable, modelInvocable, argumentHint, whenToUse, examples (all optional, backward-compatible) ### New module (commandUtils.ts + commandUtils.test.ts) - getEffectiveSupportedModes(): 3-priority inference (explicit supportedModes > commandType > CommandKind fallback) - filterCommandsForMode(): replaces filterCommandsForNonInteractive() - 18 unit tests ### Whitelist removal (nonInteractiveCliCommands.ts) - Remove ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE constant - Remove filterCommandsForNonInteractive() function - Replace with CommandService.getCommandsForMode(mode) ### CommandService enhancements (CommandService.ts) - Add getCommandsForMode(mode: ExecutionMode): filters by mode, excludes hidden - Add getModelInvocableCommands(): reserved for Phase 3 model tool-call use ### Built-in command annotations (41 files) Annotate every built-in command with commandType: - commandType='local' + supportedModes all-modes: btw, bug, compress, context, init, summary (replaces the 6-command whitelist) - commandType='local' interactive-only: export, memory, plan, insight - commandType='local-jsx' interactive-only: all remaining ~31 commands ### Loader metadata injection (4 files) Each loader stamps source/sourceLabel/commandType/modelInvocable on every command it emits: - BuiltinCommandLoader: source='builtin-command', modelInvocable=false - BundledSkillLoader: source='bundled-skill', commandType='prompt', modelInvocable=true - command-factory (FileCommandLoader): source per extension/user origin, commandType='prompt', modelInvocable=!extensionName - McpPromptLoader: source='mcp-prompt', commandType='prompt', modelInvocable=true ### Bug fix MCP_PROMPT commands were incorrectly excluded from non-interactive/ACP modes by the old whitelist logic. commandType='prompt' now correctly allows them in all modes. ### Session.ts / nonInteractiveHelpers.ts - ACP session calls getAvailableCommands with explicit 'acp' mode - Remove allowedBuiltinCommandNames parameter from buildSystemMessage() — capability filtering is now self-contained in CommandService * fix test ci * feat(cli): Phase 2 slash command expansion + ACP fixes + UX improvements Phase 2.1 - Command mode expansion: - Extend 13 built-in commands to support non_interactive/acp modes - A class: export, plan, statusline - supportedModes only - A+ class: language, copy, restore - add non-interactive branches - A' class: model, approvalMode - handle dialog paths in non-interactive - B class: about, stats, insight, docs, clear - full non-interactive branches - context: format output as readable Markdown instead of raw JSON - export: use HTML as default format when no subcommand given Phase 2.2 - SkillTool integration: - SkillTool now consumes CommandService.getModelInvocableCommands() Phase 2.3 - Mid-input slash ghost text: - Replace mid-input dropdown completion with inline ghost text - Match Claude Code behavior: gray dimmed completion hint in input box - Tab accepts the ghost text completion - Add findMidInputSlashCommand() and getBestSlashCommandMatch() utilities ACP session bug fixes: - Fix executionMode undefined in interactive mode (slashCommandProcessor) - Fix slash command output not visible in Zed (use emitAgentMessage) - Fix newline rendering in Zed (Markdown hard line-break) - Fix history replay merging consecutive user messages (recordSlashCommand) - Fix /clear not clearing model context (dynamic chat reference) * feat: inline complete only for modelInvocable * fix memory command * fix: pass 'non_interactive' mode explicitly to getAvailableCommands - Fix critical bug in nonInteractiveHelpers.ts: loadSlashCommandNames was calling getAvailableCommands without specifying mode, causing it to default to 'acp' instead of 'non_interactive'. Commands with supportedModes that include 'non_interactive' but not 'acp' would be silently excluded. - Apply the same fix in systemController.ts for the same reason. - Update test mock to delegate filtering to production filterCommandsForMode() instead of duplicating the logic inline, preventing divergence. Fixes review comments by wenshao and tanzhenxin on PR #3283. * fix: resolve TypeScript type error in nonInteractiveHelpers.test.ts * fix test ci * fix mcp prompt in skill manager * revert pr#3345 * fix test ci * feat(cli): adapt /insight for non_interactive mode with message return - non_interactive: run generateStaticInsight() synchronously with no-op progress callback, return { type: 'message' } with output path - acp: keep existing stream_messages path with progress streaming - interactive: unchanged Add tests for non_interactive success and error paths. Update phase2-technical-design.md and roadmap.md to reflect the three-way mode split and clarify that MCP prompts do not need modelInvocable (they are called via native MCP tool call mechanism). * fix(cli): ghost text only shown when cursor is at end of slash token Use strict equality (!==) instead of > in findMidInputSlashCommand so that ghost text is only computed and Tab-accepted when the cursor sits exactly at the trailing edge of the partial command token. Previously, with the cursor inside an already-typed token (e.g. /re|view), the ghost text suffix would still be shown and pressing Tab would insert it at the cursor position, producing a duplicated tail. Using strict equality makes ghost text disappear as soon as the cursor moves inside the token. Add unit tests for findMidInputSlashCommand covering cursor-at-end, cursor-inside-token, cursor-past-token, start-of-line, and no-space-before-slash cases. * fix(cli): support /model in non-interactive and ACP modes Previously, /model (without --fast) fell through to the non-interactive branch that only returned the current model info and incorrectly told users to use --fast. Now: - /model → sets the main model via settings + config.setModel() - /model → shows current model with correct usage hint - /model --fast → unchanged (sets fast model) Fixes the inconsistency flagged in PR review: the help text said to use '/model ' but the command returned a dialog action which is unsupported in non-interactive mode. * fix(cli): declare supportedModes on doctorCommand to enable non-interactive and ACP The command's action already had non-interactive handling (returns a JSON message with check results), but without supportedModes declared the BUILT_IN fallback restricted it to interactive-only so it was never registered in non_interactive or acp sessions. * feat(skills): add SkillCommandLoader for user/project/extension skills as slash commands - New SkillCommandLoader loads user, project, and extension level SKILL.md files as slash commands (previously only bundled skills were slash-invocable) - Extension skills follow plugin-command rules: modelInvocable only when description or whenToUse is present - User/project skills are always modelInvocable (matching bundled behavior) - skill-manager now injects extensionName when loading extension-level skills - Add when_to_use and disable-model-invocation frontmatter support to SKILL.md and .md command files (SkillConfig, markdown-command-parser, command-factory, BundledSkillLoader, FileCommandLoader) - SkillTool filters out skills with disableModelInvocation and includes whenToUse in the skill description shown to the model - 16 unit tests for SkillCommandLoader covering all cases * docs: update phase2 design doc to reflect final decisions on plan/statusline/copy/restore These four commands are intentionally kept as interactive-only by design: - /plan and /statusline: tightly coupled with interactive multi-turn UI - /copy and /restore: clipboard and snapshot restore are inherently interactive Update design doc classification table, section 4.2, 4.3, 5.2, 5.3, file change summary, test requirements, behavior analysis table, and implementation batch descriptions to reflect this decision. * feat(cli): re-implement slashCommands.disabled denylist based on current refactored code Adapts the feature originally introduced in pr#3445 to the current CommandService / Phase-2 refactored code. Sources (merged, de-duplicated, case-insensitive): - settings key slashCommands.disabled (string[], UNION merge) - --disabled-slash-commands CLI flag (comma-separated or repeated) - QWEN_DISABLED_SLASH_COMMANDS environment variable Enforcement points: - CommandService.create() accepts optional disabledNames: ReadonlySet and removes matching commands post-rename, so disabled commands never appear in autocomplete, mid-input ghost text, or model-invocable commands list. - slashCommandProcessor (interactive TUI) passes the denylist to CommandService.create so disabled commands are absent from dropdown/ghost text. - nonInteractiveCliCommands.handleSlashCommand() keeps allCommands unfiltered to distinguish disabled vs unknown; disabled commands return unsupported with a "disabled by the current configuration" reason (not no_command). - getAvailableCommands() (ACP) passes the denylist to CommandService.create. Config plumbing: - core/Config: ConfigParameters.disabledSlashCommands + getDisabledSlashCommands() - cli/config: CliArgs.disabledSlashCommands + yargs option + loadCliConfig merge - settingsSchema: slashCommands.disabled (MergeStrategy.UNION) - settings.schema.json: regenerated Tests: 28 pass (CommandService x4, nonInteractiveCliCommands x3 new cases) * feat(cli): complete slashCommands.disabled coverage from pr#3445 Fill in the three items that were missing from the initial re-implementation: - packages/cli/src/config/settings.test.ts: add UNION-merge test for slashCommands.disabled across user and workspace scopes - packages/cli/src/nonInteractiveCli.test.ts: add getDisabledSlashCommands mock to the shared mockConfig fixture - docs/users/configuration/settings.md: add slashCommands section (table + example + note) and --disabled-slash-commands row in the CLI args table * fix(cli): match disabled slash commands by alias as well as primary name The denylist previously only checked cmd.name (the primary/canonical name), so disabling a command by its alias (e.g. 'about' for the 'status' command) had no effect. Fix both CommandService.create() and the isDisabled() helper in nonInteractiveCliCommands.ts to also check altNames. Also improve the user-facing error message to show the token the user actually typed (e.g. /about) instead of always showing the primary name (/status). --- .../slash-command/phase2-technical-design.md | 688 ++++++++++++++++++ docs/design/slash-command/roadmap.md | 89 ++- packages/cli/src/acp-integration/acpAgent.ts | 3 - .../session/HistoryReplayer.ts | 32 +- .../acp-integration/session/Session.test.ts | 5 +- .../src/acp-integration/session/Session.ts | 95 ++- packages/cli/src/config/config.ts | 14 +- packages/cli/src/config/settingsSchema.ts | 60 +- packages/cli/src/nonInteractiveCli.test.ts | 4 +- .../cli/src/nonInteractiveCliCommands.test.ts | 69 +- packages/cli/src/nonInteractiveCliCommands.ts | 87 ++- .../cli/src/services/BundledSkillLoader.ts | 4 +- .../cli/src/services/CommandService.test.ts | 129 ++-- packages/cli/src/services/CommandService.ts | 13 +- .../cli/src/services/FileCommandLoader.ts | 3 + packages/cli/src/services/McpPromptLoader.ts | 2 - .../src/services/SkillCommandLoader.test.ts | 290 ++++++++ .../cli/src/services/SkillCommandLoader.ts | 107 +++ packages/cli/src/services/command-factory.ts | 8 +- .../cli/src/services/commandUtils.test.ts | 82 +-- packages/cli/src/services/commandUtils.ts | 37 +- .../src/services/markdown-command-parser.ts | 3 + .../cli/src/test-utils/mockCommandContext.ts | 1 + .../cli/src/ui/commands/aboutCommand.test.ts | 61 ++ packages/cli/src/ui/commands/aboutCommand.ts | 24 +- packages/cli/src/ui/commands/agentsCommand.ts | 6 +- .../src/ui/commands/approvalModeCommand.ts | 5 +- packages/cli/src/ui/commands/arenaCommand.ts | 10 +- packages/cli/src/ui/commands/authCommand.ts | 2 +- .../cli/src/ui/commands/btwCommand.test.ts | 105 --- packages/cli/src/ui/commands/btwCommand.ts | 53 +- packages/cli/src/ui/commands/bugCommand.ts | 1 - .../cli/src/ui/commands/clearCommand.test.ts | 61 ++ packages/cli/src/ui/commands/clearCommand.ts | 11 +- .../cli/src/ui/commands/compressCommand.ts | 1 - .../cli/src/ui/commands/contextCommand.ts | 164 ++++- packages/cli/src/ui/commands/copyCommand.ts | 2 +- .../cli/src/ui/commands/directoryCommand.tsx | 6 +- .../cli/src/ui/commands/docsCommand.test.ts | 20 + packages/cli/src/ui/commands/docsCommand.ts | 14 +- packages/cli/src/ui/commands/doctorCommand.ts | 1 + packages/cli/src/ui/commands/editorCommand.ts | 2 +- packages/cli/src/ui/commands/exportCommand.ts | 11 +- .../cli/src/ui/commands/extensionsCommand.ts | 8 +- packages/cli/src/ui/commands/helpCommand.ts | 2 +- packages/cli/src/ui/commands/hooksCommand.ts | 4 +- packages/cli/src/ui/commands/ideCommand.ts | 12 +- packages/cli/src/ui/commands/initCommand.ts | 1 - .../src/ui/commands/insightCommand.test.ts | 58 ++ .../cli/src/ui/commands/insightCommand.ts | 45 +- .../cli/src/ui/commands/languageCommand.ts | 8 +- packages/cli/src/ui/commands/mcpCommand.ts | 2 +- packages/cli/src/ui/commands/memoryCommand.ts | 2 +- .../cli/src/ui/commands/modelCommand.test.ts | 53 ++ packages/cli/src/ui/commands/modelCommand.ts | 46 +- .../cli/src/ui/commands/permissionsCommand.ts | 2 +- packages/cli/src/ui/commands/planCommand.ts | 2 +- packages/cli/src/ui/commands/quitCommand.ts | 2 +- .../cli/src/ui/commands/restoreCommand.ts | 2 +- packages/cli/src/ui/commands/resumeCommand.ts | 2 +- .../cli/src/ui/commands/settingsCommand.ts | 2 +- .../cli/src/ui/commands/setupGithubCommand.ts | 2 +- packages/cli/src/ui/commands/skillsCommand.ts | 2 +- .../cli/src/ui/commands/statsCommand.test.ts | 71 ++ packages/cli/src/ui/commands/statsCommand.ts | 78 +- .../cli/src/ui/commands/statuslineCommand.ts | 2 +- .../cli/src/ui/commands/summaryCommand.ts | 1 - .../src/ui/commands/terminalSetupCommand.ts | 2 +- packages/cli/src/ui/commands/themeCommand.ts | 2 +- packages/cli/src/ui/commands/toolsCommand.ts | 2 +- packages/cli/src/ui/commands/trustCommand.ts | 2 +- packages/cli/src/ui/commands/types.ts | 27 +- packages/cli/src/ui/commands/vimCommand.ts | 2 +- .../cli/src/ui/components/InputPrompt.tsx | 50 +- .../cli/src/ui/hooks/slashCommandProcessor.ts | 53 +- .../cli/src/ui/hooks/useCommandCompletion.tsx | 43 +- .../cli/src/ui/utils/commandUtils.test.ts | 49 ++ packages/cli/src/ui/utils/commandUtils.ts | 81 +++ .../src/utils/nonInteractiveHelpers.test.ts | 80 +- packages/core/src/config/config.ts | 60 +- packages/core/src/skills/skill-manager.ts | 16 +- packages/core/src/skills/types.ts | 15 + packages/core/src/tools/skill.test.ts | 158 ++++ packages/core/src/tools/skill.ts | 107 ++- .../schemas/settings.schema.json | 26 +- 85 files changed, 2934 insertions(+), 635 deletions(-) create mode 100644 docs/design/slash-command/phase2-technical-design.md create mode 100644 packages/cli/src/services/SkillCommandLoader.test.ts create mode 100644 packages/cli/src/services/SkillCommandLoader.ts 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",