Back to Qwen Code

Phase 2 技术设计文档:能力扩展

docs/design/slash-command/phase2-technical-design.md

0.15.637.3 KB
Original Source

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() 为两级推断:显式 supportedModesCommandKind 兜底
  • CommandService.getCommandsForMode(mode) 取代原 ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE 白名单
  • btwbugcompresscontextinitsummary 已在 Phase 1 中扩展到全模式,不在本阶段列表中
  • createNonInteractiveUI() 中各方法均为 no-op:addItemclearsetDebugMessagesetPendingItemreloadCommands 均静默忽略调用

3. 变更范围总览

本阶段共涉及 13 个命令,按实现复杂度分为四类:

类别命令变更要点
A 类export只改 supportedModes,action 所有路径已返回合法类型
仅交互planstatusline设计决策:这两个命令语义上与交互界面紧密耦合,保持 supportedModes: ['interactive']
A+ 类languagesupportedModes + 少量 non-interactive 分支处理
仅交互copyrestore设计决策:剥贴板和快照恢复本质上是交互操作,保持 supportedModes: ['interactive']
A' 类modelapproval-mode有参数路径已返回 message,无参数路径需新增 non-interactive 分支(现触发 dialog)
B 类aboutstatsinsightdocsclearaction 所有路径均无返回值或调用 addItem/clear,需新增完整 non-interactive 分支

4. A 类:只改 supportedModes

这三个命令的所有 action 路径已经返回 messagesubmit_prompt,完全无 UI 依赖,handleCommandResult 可直接处理。

4.1 /export(及子命令)

当前状态supportedModes: ['interactive'],所有子命令 action 均返回 MessageActionReturn

变更:将父命令及所有四个子命令(mdhtmljsonjsonl)的 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 所有路径返回 MessageActionReturnSubmitPromptActionReturn

设计决策/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,无需额外处理。

变更

  • 将父命令及子命令(uioutput,以及 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 <model-id>未实现(只有 --fast 分支)
/model --fast(无 model name){ type: 'dialog', dialog: 'fast-model' }(non-interactive 下变 unsupported)
/model --fast <model-id>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 <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)✅

变更

  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 <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,包含:cliVersionosPlatformosArchosReleasenodeVersionmodelVersionselectedAuthTypeideClientsessionIdmemoryUsagebaseUrlapiKeyEnvKeygitCommitfastModel。所有字段在 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<HistoryItemAbout, 'id'> = { type: MessageType.ABOUT, systemInfo };
  context.ui.addItem(aboutItem, Date.now());
},

7.2 /stats(及子命令 modeltools

数据来源context.session.statsSessionStatsState)包含 sessionStartTimemetricsSessionMetricsmodelstoolsfiles)、promptCount。在 non-interactive 中,sessionStartTime 为当前调用时刻,metrics 来自 uiTelemetryService.getMetrics()(本次调用的累积值,通常为零),promptCount 为 1。

变更

  1. 将父命令 stats 及子命令 modeltoolssupportedModes 改为 ['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());
},

子命令 modeltools 也各自插入模式分支,返回对应维度的文本统计(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<void | MessageActionReturn>
  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: resetnew

当前状态: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<void | MessageActionReturn>
  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 路径的返回类型均为 messagesubmit_prompt,均已在 handleCommandResult 的 switch 中正确处理。


9. createNonInteractiveUI() 变更

结论:无需修改。

当前 no-op 实现已足够。addItemclearsetPendingItem 等 no-op 在 B 类命令的 non-interactive 路径中不会被调用(因为提前 return);interactive 路径中不受影响。


10. Phase 2.2:prompt command 模型调用打通

Phase 1 中 CommandService.getModelInvocableCommands() 已实现,BundledSkillLoaderFileCommandLoader(用户/项目命令)、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.tsA 类父命令 + 4 个子命令:supportedModes → all modes
planCommand.ts仅交互设计决策:保持 supportedModes: ['interactive'],未变更
statuslineCommand.ts仅交互设计决策:保持 supportedModes: ['interactive'],未变更
languageCommand.tsA+ 类父命令 + ui/output 子命令 + 动态 language 子命令:supportedModes → all modes
copyCommand.ts仅交互设计决策:保持 supportedModes: ['interactive'],未变更
restoreCommand.ts仅交互设计决策:保持 supportedModes: ['interactive'],未变更
modelCommand.tsA' 类supportedModes → all modes + 无参数/无 fast model 路径新增非交互分支
approvalModeCommand.tsA' 类supportedModes → all modes + 无参数路径新增非交互分支
aboutCommand.tsB 类supportedModes → all modes + 非交互路径返回 message(版本/模型/环境摘要)
statsCommand.tsB 类supportedModes → all modes + 非交互路径返回 message(stats 文本);子命令同步处理
insightCommand.tsB 类supportedModes → all modes + non_interactive 路径同步生成返回 message(文件路径);acp 路径返回 stream_messages 带进度推送
docsCommand.tsB 类supportedModes → all modes + 非交互路径返回 message(文档 URL),不打开浏览器
clearCommand.tsB 类supportedModes → all modes + action 末尾根据模式返回 messagevoid

12.2 其他文件变更

文件变更内容
packages/core/src/tools/SkillTool.tsPhase 2.2:接入 getModelInvocableCommands()(详细设计另行确定)
packages/cli/src/ui/InputPrompt.tsx(或同等组件)Phase 2.3:mid-input slash 检测逻辑

12.3 不变的文件

  • packages/cli/src/nonInteractiveCliCommands.tshandleCommandResulthandleSlashCommand 无需修改)
  • packages/cli/src/ui/noninteractive/nonInteractiveUi.ts(stub UI 无需修改)
  • packages/cli/src/services/commandUtils.tsfilterCommandsForModegetEffectiveSupportedModes 无需修改)
  • packages/cli/src/services/CommandService.tsgetCommandsForModegetModelInvocableCommands 已在 Phase 1 实现)

13. 测试策略

13.1 命令单元测试

为每个变更的命令在同目录下新增或更新测试文件(*.test.ts),覆盖以下 case:

A/A+ 类命令exportlanguage):

  • supportedModes 正确包含 non_interactiveacp
  • executionMode: 'non_interactive' 下,action 返回 MessageActionReturnSubmitPromptActionReturn,不调用 ui.addItemui.clear
  • Interactive 路径行为与重构前完全一致(快照测试)

仅交互命令planstatuslinecopyrestore):

  • supportedModes['interactive'],这是设计决策
  • 验证 non-interactive 下执行时正确返回 unsupported

A' 类命令modelapproval-mode):

  • 无参数 + executionMode: 'non_interactive' → 返回当前状态 message,不返回 dialog
  • 有参数 + executionMode: 'non_interactive' → 原有 message 逻辑正常执行
  • Interactive 路径:无参数 → dialog,有参数 → message(不变)

B 类命令aboutstatsinsightdocsclear):

  • 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 命令(btwbug 等)行为无退化

13.3 commandUtils 测试

commandUtils.test.ts 中新增(或已有的测试继续覆盖):

  • 扩展后的命令(exportlanguage 等)均能通过 filterCommandsForMode(commands, 'non_interactive')filterCommandsForMode(commands, 'acp') 的过滤
  • 仅交互命令(planstatuslinecopyrestore)在 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."✅ 返回文档 URLBug fix + 能力扩展
non-interactive 下执行 /clear❌ 返回 "Command executed successfully."✅ 返回上下文边界 messageBug fix + 能力扩展
interactive 下执行任意以上命令✅ 原有行为✅ 原有行为(零退化)不变

15. 实施顺序

建议按以下顺序实施,每组可独立 commit 和 review:

Batch 1(~30min):A 类 — 只改 supportedModes

修改 exportCommand.ts(及其子命令),验证测试通过。

Batch 2(~45min):A+ 类 — 少量分支

修改 languageCommand.ts,为有副作用的路径添加非交互分支,更新对应测试。(copyCommand.tsrestoreCommand.ts 经讨论保持仅交互。)

Batch 3(~45min):A' 类 — dialog 路径

修改 modelCommand.tsapprovalModeCommand.ts,为无参数路径添加非交互分支,更新对应测试。

Batch 4(~1.5h):B 类 — 完整分支

修改 aboutCommand.tsstatsCommand.ts(含子命令)、docsCommand.ts

Batch 5(~1h):B 类特殊 — insightCommand.tsclearCommand.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 typecheckcd 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
  • 补全选中后正确填充到输入框