Day 11 / 共 20 天 · 第 3 周 扩展能力 · 加深版

Slash 命令系统

昨天(Day 10)讲了上下文/token 怎么管理;今天讲你敲的 "/help /model /compact /init" 这些斜杠命令怎么定义、注册、解析分派,为明天 Day 12 的 MCP、Day 13 的 Skills 埋下"它们其实都是同一种 Command"的伏笔。贴 command.ts 真实类型逐段讲。

📍 你在整门课的位置 · 第 3 周「扩展能力」(斜杠命令 → MCP → 技能 → 钩子 → 子代理,本质都是"给 Agent 加料")
D10 上下文管理 D11 Slash 命令 D12 MCP 客户端 D13 Skills 技能 D14 Hooks D15 子代理
L01

什么是 Slash 命令

🤔 痛点:如果没有斜杠命令 你想换个模型、清空对话、看看用了多少钱——难道每次都用自然语言跟模型说"请帮我切换到 opus 模型",然后等它猜你的意思、再去调工具?既慢又不稳,有的操作(比如清屏、退出)根本不该惊动模型。
💡 本质:斜杠命令 = 遥控器上的快捷键 斜杠命令就像电视遥控器上的实体按钮:不用跟电视"商量",按一下"静音/换台"立刻生效。/ 开头 = 我要按一个明确的功能键,走本地快捷通道,不必每次都劳驾"大脑"(模型)去理解。

在输入框里以 / 开头的就是斜杠命令,比如 /help(看帮助)、/model(换模型)、/compact(手动压缩上下文,Day 10)、/clear(清空对话)。它们大多是不发给模型的本地功能,或者展开成一段给模型的指令(如 /init)。

核心文件:类型定义 src/types/command.ts、注册表 src/commands.ts、解析 src/utils/slashCommandParsing.ts、派发 src/utils/processUserInput/processSlashCommand.tsx

L02

三种命令形态(真实类型)

一个 Command = 公共基类 + 三选一形态。这是理解整个系统的钥匙。真实类型(command.ts:16:25):

// local 命令的返回:文本 / 压缩结果 / 跳过
export type LocalCommandResult =
  | { type: 'text'; value: string }
  | { type: 'compact'; compactionResult: CompactionResult; ... }
  | { type: 'skip' }

// prompt 命令:会展开成内容喂给模型
export type PromptCommand = {
  type: 'prompt'
  source: SettingSource | 'builtin' | 'mcp' | 'plugin' | 'bundled'   // ← 记住这个 source
  context?: 'inline' | 'fork'   // inline=展进当前对话;fork=当子代理跑
  hooks?: HooksSettings         // 技能可自带 hooks
  // ...
}

type: 'prompt'

展开成文本/内容块喂给模型。如 /init、技能。

type: 'local'

纯本地逻辑,不查模型,返回 text/compact/skip。如 /clear/compact

type: 'local-jsx'

渲染一个 Ink UI 面板。如 /help/model/mcp

为什么分这三种? 因为斜杠命令干的事天差地别:有的只是本地清屏(local)、有的要弹选择面板(local-jsx)、有的其实是"帮我把这句展开成给模型的长指令"(prompt)。用一个 type 字段区分,派发时按类型走不同逻辑。注意 PromptCommand 里那个 source 字段(builtin/mcp/plugin/bundled)——L07 的大一统洞察就靠它。context:'fork' 表示这个命令作为独立子代理跑(Day 15)。
L03

命令注册表:memoize 的函数

注册中心 src/commands.ts。核心是 COMMANDS:297)——一个 memoize 的函数,返回命令数组,而非模块级常量:

const COMMANDS = memoize((): Command[] => [
  clearCmd, helpCmd, modelCmd, compactCmd,
  feature('DAEMON') && daemonCmd,     // ← feature 门控,关了就不加载
  // ...
].filter(Boolean))
为什么用函数而非常量? 因为很多命令的 isEnabled 要读运行时配置——不能在模块加载那一刻就求值。用函数 + memoize(Day 02 讲过的"记忆化"):第一次调用才计算(此时配置已就绪),之后缓存复用。feature('X') && cmd + .filter(Boolean) = "X 功能开着就加这个命令,关着就过滤掉"(配合死代码消除)。

命令来源不止内置。loadAllCommands:536并行加载多个来源:内置命令 + bundled skills + 插件命令 + 插件 skills + .claude/skills 目录 skills + workflow 命令。

L04

懒加载:启动只读元数据

locallocal-jsx 命令都用懒加载——注册时只声明元数据,真被调用时才 import 实现。/clear 就是范例:

// src/commands/clear/index.ts —— 只有元数据 + 一个 load()
export default {
  type: 'local',
  name: 'clear',
  aliases: ['reset', 'new'],
  description: '清空会话历史',
  load: () => import('./clear.js'),   // ← 用到才加载真正的实现
}
为什么懒加载? 有几百个命令。启动时把每个命令的完整实现都加载进来,冷启动会很慢。只加载"元数据"(名字/描述/别名——用来做自动补全和帮助列表),实现代码等你真敲了 /clear 才加载。这跟 Day 02 cli.tsx 的 fast-path 动态 import 是同一种"按需加载优化冷启动"思路,贯穿整个项目。
L05

解析 "/x args"

你敲 /model opus,怎么拆成"命令 model + 参数 opus"?parseSlashCommandslashCommandParsing.ts:25)——很朴素:

function parseSlashCommand(text) {
  if (!text.trim().startsWith('/')) return null    // 不是斜杠命令
  const words = text.slice(1).split(/\s+/)          // 去掉 /,按空格切
  let name = words[0]                               // 第一个词 = 命令名
  // 特判:第二个词是 (MCP) 就拼进名字、标记 isMcp
  const args = words.slice(1).join(' ')             // 其余 = 参数
  return { name, args, isMcp }
}
读法:去掉开头的 /,按空格切开;第一个词是命令名,剩下拼成参数字符串。.slice(1) 去掉第一个字符;.split(/\s+/) 按空白切成数组;.slice(1).join(' ') 取除第一个外的其余、再拼回字符串。
L06

派发:按 type 走不同路

解析出命令后,processSlashCommandprocessSlashCommand.tsx:427)先判断是不是真命令(不是就当普通消息发给模型,或提示"未知命令"),是就按 type switch 派发:

你敲 /model opus parseSlashCommandname=model, args=opus switch(command.type)按类型分三条路 prompt展开成文本喂给模型 local本地跑, 返回 text/compact/skip local-jsx渲染 Ink 面板 (/model) /model opus 命中 local-jsx → 弹出模型选择面板 → 选完 onDone 插一条"已切到 opus"
图注:一条斜杠命令从输入到派发的全程——先解析成 name+args,再按 type 分三条路。
📝 举个例子:三种命令各走哪条路 输入 /clear → local → 直接清空历史、不惊动模型;
输入 /model → local-jsx → 弹出一个模型选择面板(Ink UI);
输入 /init → prompt → 展开成"请扫描本仓库并生成 CLAUDE.md……"这一大段,作为 user 消息发给模型。
J

local-jsxcommand.load()mod.call(onDone, ctx, args),返回的 JSX 通过 setToolJSX 渲染成终端面板;选完调 onDone 把结果并回对话

L

localmod.call(args, ctx),结果分 text/compact/skip 三种(就是 L02 的 LocalCommandResult

P

promptcommand.getPromptForCommand(args, ctx) 拿到内容块,做参数替换 + @-mention 附件解析,作为 user 消息交给模型(context:'fork' 的走子代理)

local-jsx 的 onDone 回调/model 弹出的模型选择面板,你选完之后要"往对话里插一条'已切换到 opus'的消息"——这就是 onDone 干的。面板是临时 UI(覆盖层,Day 04),选完调 onDone 收尾。prompt 命令的 @-mention/review @src/app.ts 里的 @文件 会被解析成附件塞进给模型的消息——命令能带上文件内容。
L07

大一统洞察:它们都是 Command(真实字段佐证)

💡 关键洞察:skills、插件命令、bundled 命令、MCP prompt —— 本质上都是 type:'prompt' 的 Command,和你手敲的斜杠命令共用同一套解析/派发管线

证据就在 L02 那个真实类型里——PromptCommand.source 的类型是:

source: SettingSource | 'builtin' | 'mcp' | 'plugin' | 'bundled'
同一个 PromptCommand 类型,靠 source 字段区分它从哪来:内置命令(builtin)、MCP 提供的(mcp)、插件的(plugin)、打包的技能(bundled)……它们是同一种东西,只是来源标签不同。所以不用为它们各写一套机制。
为什么这个洞察重要? 它把看似五花八门的东西(Day 13 的 skills、MCP 的 prompt、插件命令)统一成了一个概念——都是"一段能被触发、展开成给模型指令"的 Command。理解了这点,你不用分别学 4 套机制;它们只是同一个 Command 接口的不同 source这是优秀抽象的威力:用一个统一模型覆盖多个表面不同的需求。Day 13 讲 skills 时你会看到,一个 SKILL.md 最后就是被包成一个 type:'prompt' 的 Command;Day 15 你会看到多代理只是递归 query——都是同一种"统一抽象"思想。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 三种命令形态(prompt/local/local-jsx)各干什么?
  • 注册表 COMMANDS 为什么是 memoize 函数而非常量?
  • 命令为什么懒加载?
  • "/x args" 怎么解析、怎么按 type 派发?
  • 为什么说 skills/插件命令/MCP prompt 都是 Command?靠哪个字段区分来源?(source)

✋ 动手:对着真实代码读一遍

# 1. 命令三形态类型 + source 字段(L02/L07)
sed -n '16,55p' src/types/command.ts

# 2. 注册表 memoize 函数(L03)
sed -n '297,340p' src/commands.ts

# 3. 一个懒加载命令(L04)
cat src/commands/clear/index.ts

# 4. 解析(L05)
cat src/utils/slashCommandParsing.ts

# 5. 派发 switch(L06)
sed -n '691,760p' src/utils/processUserInput/processSlashCommand.tsx
明天预告 · Day 12:命令是"内建扩展",MCP 是"接外部工具"。Day 12 讲 MCP 客户端——怎么连外部 MCP server、发现工具、把外部工具和内置工具无缝并列给模型(正是 Day 07 "两层接口"的回报)。

← Day 10 上下文管理 Day 12 · MCP 客户端 →