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接口中删除,所有命令改用显式supportedModesgetEffectiveSupportedModes()为两级推断:显式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 不可用)。
变更:
- 将
supportedModes改为['interactive', 'non_interactive', 'acp']。 - 在 action 内新增模式分支:
// 获取 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 <model-id> | 未实现(只有 --fast 分支) |
/model --fast(无 model name) | → { type: 'dialog', dialog: 'fast-model' }(non-interactive 下变 unsupported) |
/model --fast <model-id> | → MessageActionReturn ✅ |
变更:
- 将
supportedModes改为['interactive', 'non_interactive', 'acp']。 - 在 action 内各 dialog 路径前插入 non-interactive 分支:
// 无参数路径(原返回 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 <model-id>" 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 <model-id>" to set fast model.`,
};
}
return { type: 'dialog', dialog: 'fast-model' };
}ACP 语义:IDE 展示当前模型名称,供用户参考;切换模型通过带参数调用实现(/model <model-id>)。
注意:
/model <model-id>(不带--fast)目前没有实现设置当前 session 模型的逻辑,只有--fast <model-id>有。如果 Phase 2 要支持 ACP 下切换主模型,需要同步实现/model <model-id>的 set 逻辑。本设计预留此路径但标记为 Phase 2 可选项,优先保证”查看当前模型”的 read-only 路径。
6.2 /approval-mode
当前状态:
| 输入 | 当前行为 |
|---|---|
/approval-mode(无参数) | → { type: 'dialog', dialog: 'approval-mode' }(non-interactive 下变 unsupported) |
/approval-mode <mode> | → MessageActionReturn ✅ |
/approval-mode <invalid> | → MessageActionReturn(error)✅ |
变更:
- 将
supportedModes改为['interactive', 'non_interactive', 'acp']。 - 在无参数路径(
!args.trim())插入 non-interactive 分支:
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 <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 均已注入)。
变更:
- 将
supportedModes改为['interactive', 'non_interactive', 'acp']。 - 在
getExtendedSystemInfo调用后,interactive 路径之前插入模式分支:
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<HistoryItemAbout, 'id'> = { 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。
变更:
- 将父命令
stats及子命令model、tools的supportedModes改为['interactive', 'non_interactive', 'acp']。 - 父命令和每个子命令的 action 均插入模式分支,提前返回文本格式统计:
// /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 文件。
变更:
- 将
supportedModes改为['interactive', 'non_interactive', 'acp']。 - 按
executionMode三路分叉:non_interactive:同步生成,忽略进度回调,不开浏览器,直接返回message(文件路径)acp:异步启动生成,通过stream_messages将进度(encodeInsightProgressMessage)和完成(encodeInsightReadyMessage)推送给 IDEinteractive:原有addItem+setPendingItem+open()逻辑不变
// 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,不开浏览器)。
变更:
- 将
supportedModes改为['interactive', 'non_interactive', 'acp']。 - 修改 action 返回类型为
Promise<void | MessageActionReturn>。 - 在 action 开头插入 non-interactive 分支:
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:
config.getHookSystem()?.fireSessionEndEvent()— 触发 hook(有副作用)config.startNewSession()— 开始新 session ID(有副作用)uiTelemetryService.reset()— 重置 telemetry 计数器(有副作用)skillTool.clearLoadedSkills()— 清除 skill 缓存(有副作用)context.ui.clear()— 清空终端 UI(UI 副作用,non-interactive 下为 no-op)geminiClient.resetChat()— 重置 chat 历史(有副作用)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。
变更:
- 将
supportedModes改为['interactive', 'non_interactive', 'acp']。 - 修改 action 返回类型为
Promise<void | MessageActionReturn>。 - 在 action 内,
context.ui.clear()调用后(或替代它)根据模式分支:
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(或对应路径)
具体变更:
SkillTool在初始化时接收CommandService(或其getModelInvocableCommands()的结果)作为依赖注入- 在构建 tool description 时,合并
listSkills()和getModelInvocableCommands()的结果 - 确保 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 <task> | ❌ 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 <id> | ❌ unsupported | ❌ unsupported(设计决策:仅交互) | 不变 |
non-interactive 下执行 /model | ❌ unsupported(dialog) | ✅ 返回当前模型名称 | 能力扩展 |
non-interactive 下执行 /model <id> | ❌ 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 <id>正常设置 - 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
- 补全选中后正确填充到输入框