# Step 35: 斜杠命令框架
一句话导读:Phase 4「Agent 智能内核」开篇。行为一行没变,只把「十几个
if (input==="/xxx")」重构成一张命令注册表,把「满地飞的let」收进一个 AppState 对象——为后面接 Hooks / Plan Mode / 子代理铺好插槽。
# 一、这一步做了什么(What)
纯重构,对外行为零变化,只翻新组织方式,解决 step34 源码对照时点出的两个问题:
- 命令写死在 if-else 里:
/debug、/think、/model… 十几个if (input === "/xxx")堆在主循环,又长又难扩展。 - 状态散落成一堆
let:debugMode / permMode / currentStyle / thinking / currentModel…到处都是,谁在哪改了不好追。
新结构三个文件:
commands/
├── types.ts Command 接口 + CommandContext + AppState
├── registry.ts CommandRegistry:register / list / dispatch
└── builtins.ts 17 个内置命令(每条一个对象)
2
3
4
主循环里命令相关的 ~190 行,塌缩成 1 行 await commands.dispatch(input, ctx)。
# 二、面试官视角:为什么要做?(Why)
面试题:命令用
if-else跑得好好的,为什么非要引入「注册表 + 上下文」这套额外抽象?它到底解决了什么真问题?
三个真问题,恰好对应三个后续会反复用到的模式:
1. 扩展性——if 链是控制流驱动,注册表是数据驱动。 加一个命令,if 链要在主循环里再塞一段(还得小心它和上一段的顺序、return);注册表只要往 builtins.ts 加一个对象。数据驱动天然可自描述——/help 直接遍历注册表打印每个命令的 desc,新命令自动出现在帮助里,不用手动同步文档。
2. 状态可追踪——散落的 let 变成一个 store。 十几个顶层 let 意味着状态改动点遍布全文件。收进 AppState 后,所有修改都走 ctx.state.xxx = ...,一个对象即「当前会话真相」。这也接上了 step30 引擎的设计:引擎的 getter(getModel/getThinking)从同一个 state 读,命令改完 getter 立刻读到最新值,不用来回同步。
3. 解耦——命令也走依赖注入。 命令不 import 全局单例,能力全从 ctx 拿。这让命令可测、可复用,也让后续能力(hooks、memory、skills)能顺着 ctx 一路注入进来,而不是每加一个能力就去戳一次全局。
一句话:这一步买的不是「现在好看一点」,而是为 Phase 4 后面 7 步预留了统一的扩展插槽。
# 三、原理:它是怎么工作的(How)
# 注册表:一个 Map + 一次 dispatch
CommandRegistry 本体极简——就是围绕一个 Map<string, Command>:
export class CommandRegistry {
private cmds = new Map<string, Command>();
register(c: Command) { this.cmds.set(c.name, c); }
list(): Command[] { return [...this.cmds.values()]; }
async dispatch(input: string, ctx: CommandContext): Promise<boolean> {
if (!input.startsWith("/")) return false; // 不是命令,交回普通对话
const [name, ...args] = input.slice(1).split(/\s+/);
const cmd = this.get(name);
if (!cmd) { showErr("未知命令:/" + name + "(输入 /help 看全部)"); return true; }
await cmd.run(ctx, args);
return true; // 已作为命令处理
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
dispatch 的返回值是控制流的关键:true = 「这是命令,我处理了」,false = 「不是命令,请当普通对话」。主循环据此决定 continue 还是走对话逻辑。
# AppState + Command 契约
export interface AppState { // 运行时可变状态收口到一个对象
debugMode: boolean; permMode: string; style: string;
thinking: boolean; model: ModelDef; turnCount: number;
lastSaveName: string; lastCompact: number;
}
export interface CommandContext { // 命令能拿到的一切(依赖注入)
state: AppState; engine: QueryEngine; registry: ToolRegistry;
budget: TokenBudget; cost: CostTracker;
save: () => void; commandList: () => Command[]; // commandList 供 /help 用
}
export interface Command {
name: string; // 不带斜杠,如 "config"
desc: string; // /help 里显示的一句话
run(ctx: CommandContext, args: string[]): void | Promise<void>;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 数据流
用户输入 "/model opus"
↓
commands.dispatch(input, ctx)
↓ startsWith("/")?是 → 拆成 name="model", args=["opus"]
↓ Map.get("model") → 命中 Command 对象
↓ cmd.run(ctx, ["opus"]) → ctx.state.model = ... (改的是 store)
↓ 返回 true
↓
主循环 continue(不进对话);下一轮引擎 getModel() 从同一 state 读到新模型
2
3
4
5
6
7
8
9
# 主循环瘦身对照
step33 index.ts step35 index.ts
───────────── ──────────────
while (input) { while (input) {
if (input==="/debug"){...} if (input==="exit") break
if (input==="/think"){...} if (input==="demo") {...}
...十几个 if... (~190 行) if (await commands.dispatch(input, ctx)) continue
// 普通对话 // 普通对话
} }
2
3
4
5
6
7
8
# 四、深入追问(面试常见 follow-up)
Q:为什么 exit 和 demo 不也做成命令收进注册表,反而留在主循环里当特例?
A:因为它们和普通命令语义不同。exit 要 break 主循环,这是控制流——命令的 run() 无法 break 它外层的 while,硬做要靠抛异常或返回信号,反而更绕。demo 是非斜杠的遗留演示。「注册表管纯命令,控制流特例留在循环」比「什么都硬塞进注册表」更诚实。
Q:命令改了 state,引擎为什么能立刻看到,不用手动通知?
A:因为引擎拿的是读同一个 state 的 getter 函数(getModel: () => state.model.id),不是拷贝的值。命令写 state.model,getter 下次调用自然读到新值——这是 step30「注入函数而非值」的设计在这里兑现的复利。
Q:/compact(手动压缩)和「对话太长自动压缩」逻辑一样,会不会两处各写一遍?
A:不会。两者共用 builtins.ts 导出的 doCompact()。step33 时这段是重复的,这一步顺手抽成一个函数——命令和引擎自动逻辑复用同一实现,避免「改了一处忘了另一处」。
Q:dispatch 为什么要返回 boolean,而不是直接在里面处理完就算了?
A:因为主循环需要区分「已被当命令消费」和「这是普通对话」两种结局。返回 false(不以 / 开头)让主循环继续走对话分支;返回 true(无论命令成功还是未知)让它 continue。把「是否是命令」的判定收进 dispatch,主循环只需一个 if。
# 五、设计权衡
- 注册表 vs if 链:注册表多了一层抽象,命令少时(3-4 个)确实是过度设计;但命令到十几个、且后面还要持续加,数据驱动的收益就压倒了抽象成本。这是「什么时候该抽象」的典型判断点。
- 一个大 AppState vs 多个细分 store:这里选一个扁平对象,简单直接。代价是所有状态耦合在一个类型里;好处是教学项目里「一个对象即真相」最好懂。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
Command 接口 | src/commands/ 里的命令定义 |
CommandRegistry(register/list/dispatch) | 命令收表 + 按名查找执行 |
AppState 对象 | 真实的 AppState store(getAppState/setAppState) |
ctx 依赖注入 | 命令通过上下文拿能力,不碰全局单例 |
/help 遍历注册表自描述 | 真实版命令同样自带元信息 |
# 七、一句话总结
把「控制流驱动的 if 链」换成「数据驱动的注册表」,把「散落的 let」收进「一个 AppState store」,再让命令走依赖注入——这一步行为零变化,买的是 Phase 4 后面每一步都要复用的扩展插槽:加能力 = 加一个对象、顺一条 ctx。
# 下一节预告
注册表 + ctx 为后面铺好了路。step36 把 Hooks 生命周期作为新能力接进 ctx——在工具执行前后插入可编程钩子,能拦截、能改写。