一、引言:为什么需要命令系统
前九篇文章讲了 pi agent 的内部机制:agent loop、工具、hook、skills、Extension API、compaction、session 管理。这些机制让 agent 能干活、能扩展、能持久化。但它们都运行在"agent 和 LLM 对话"这个框架里——用户发消息,LLM 回复,循环往复。
但 agent 不只是聊天。用户需要:
- 切换 session——
/resume切到之前的对话 - 触发 compaction——
/compact手动压缩 context - 分叉新分支——
/fork从某个决策点换个方向 - 切换模型——
/model换到另一个 LLM - 重载扩展——
/reload热重载 Extension API
这些操作有一个共同特征:它们是不应该由 LLM 干预的原子操作。compaction 是精确的 context 压缩流程,fork 是文件级别的 session 复制,model 切换是 provider 路由变更——这些操作需要确定的程序行为,不能交给 LLM 的概率性判断。如果让 LLM 来"理解"用户说"帮我 compact"然后触发 compaction,LLM 可能理解错、可能不触发、可能触发时机不对。
slash 命令系统把这些原子操作封装成命令——用户输入 /xxx,pi 直接执行本地逻辑,绕过 LLM。
slash 命令 vs 普通消息
| slash 命令 | 普通消息 | |
|---|---|---|
| 格式 | /command [args] | 任意文本 |
| 谁处理 | pi 本地代码 | LLM |
| 经过 agent loop? | 不经过(大部分) | 经过 |
| 消耗 token? | 不消耗 | 消耗 |
| 例子 | /compact / /fork / /model | "帮我重构这个模块" |
关键分界线:slash 命令是用户和 pi 框架的交互,普通消息是用户和 LLM 的交互。slash 命令不经过 agent loop——它直接调本地 handler,如 /compact 调 AgentHarness.compact(),/fork 调 SessionManager.forkFrom()。
有一个例外:Extension 注册的命令(如 /commands)的 handler 可以调 pi.sendMessage() 触发 agent loop。但本质上,slash 命令的分派是本地完成的,不经过 LLM。
本章拆解 pi 的 slash 命令系统:命令从哪来、怎么补全、怎么解析分派、怎么执行。
二、四层来源
pi 的 slash 命令不是写死在一个地方——它们来自四个不同的来源,最终合并成统一的命令列表。
来源汇总图
flowchart TD
A[BUILTIN_SLASH_COMMANDS<br/>slash-commands.ts] -->|20 个内置命令| E[合并]
B[.pi/prompts/*.md<br/>prompt template 文件] -->|用户自定义 prompt| E
C[pi.registerCommand<br/>Extension API] -->|扩展注册的命令| E
D[skill:name<br/>skills 列表] -->|每个 skill 一个命令| E
E --> F[统一命令列表<br/>autocomplete + 分派]
1. 内置命令
BUILTIN_SLASH_COMMANDS(slash-commands.ts:18-40)是一个常量数组,定义了 20 个内置命令:
export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
{ name: "settings", description: "Open settings menu" },
{ name: "model", description: "Select model (opens selector UI)" },
{ name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
{ name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
{ name: "import", description: "Import and resume a session from a JSONL file" },
{ name: "share", description: "Share session as a secret GitHub gist" },
{ name: "copy", description: "Copy last agent message to clipboard" },
{ name: "name", description: "Set session display name" },
{ name: "session", description: "Show session info and stats" },
{ name: "changelog", description: "Show changelog entries" },
{ name: "hotkeys", description: "Show all keyboard shortcuts" },
{ name: "fork", description: "Create a new fork from a previous user message" },
{ name: "clone", description: "Duplicate the current session at the current position" },
{ name: "tree", description: "Navigate session tree (switch branches)" },
{ name: "login", description: "Configure provider authentication" },
{ name: "logout", description: "Remove provider authentication" },
{ name: "new", description: "Start a new session" },
{ name: "compact", description: "Manually compact the session context" },
{ name: "resume", description: "Resume a different session" },
{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" },
{ name: "quit", description: `Quit ${APP_NAME}` },
];
这些命令覆盖了 pi 的核心操作——session 管理(/new / /fork / /tree / /resume / /clone)、context 管理(/compact)、模型切换(/model / /scoped-models)、认证(/login / /logout)、导出(/export / /share / /copy)、系统(/settings / /reload / /quit)。
内置命令只有 name + description——没有 handler。handler 写在 interactive-mode.ts 的 onSubmit 里(第 4 章讲)。
2. Prompt template
用户在 .pi/prompts/ 下放 Markdown 文件,每个文件是一个 slash 命令:
.pi/prompts/
└── review.md → /review 命令
review.md 的内容就是命令展开后的 prompt——用户输入 /review 时,pi 把文件内容作为用户消息发给 LLM。这让用户能预定义常用 prompt,不用每次手打。
prompt template 的加载在 ResourceLoader 里做(文章 9 讲过 ResourceLoader),加载结果存入 session.promptTemplates。
3. Extension 命令
Extension 通过 pi.registerCommand(name, options) 注册(文章 7 讲过):
export default function (pi: ExtensionAPI) {
pi.registerCommand("deploy", {
description: "Deploy to production",
handler: async (args, ctx) => {
// 命令逻辑
},
});
}
注册结果存入 extension.commands Map(文章 7 讲过)。ExtensionRunner.getRegisteredCommands() 返回所有 extension 注册的命令。
Extension 命令和内置命令的区别:handler 在 extension 里定义,不在 onSubmit 里。分派时 onSubmit 检查 isExtensionCommand(text),是的话走 session.prompt(text) 交给 extension runner 执行。
4. Skill 命令
每个 skill 自动注册一个 /skill:{name} 命令。如 skill code-review 注册 /skill:code-review——用户输入这个命令,pi 直接 read SKILL.md 全文并作为消息发给 LLM。
这和文章 6 讲的"LLM 自动触发 skill"不同——skill 命令是用户显式触发,不依赖 LLM 判断 description 是否匹配。用户明确说"我要用这个 skill",pi 直接执行。
skill 命令的注册在 createBaseAutocompleteProvider 里动态生成(第 3 章讲),受 enableSkillCommands 配置控制(默认开启)。
命名规则
四层来源的命名规则不同:
| 来源 | 命名规则 | 例子 |
|---|---|---|
| 内置 | {name} | /compact / /fork |
| Prompt template | {filename} | /review(来自 review.md) |
| Extension | {name}(registerCommand 的参数) | /deploy |
| Skill | skill:{skill-name} | /skill:code-review |
如果 extension 命令和内置命令重名,内置命令优先——extension 命令在合并时被过滤(第 3 章讲)。
三、自动补全
四层来源的命令最终要合并成一个 autocomplete 列表——用户输入 / 时弹出候选,输入 /co 时 fuzzy 匹配。这靠 createBaseAutocompleteProvider(interactive-mode.ts:472-547)完成。
1. 合并四层来源
// interactive-mode.ts:472-547(简化)
private createBaseAutocompleteProvider(): AutocompleteProvider {
// 第 1 层:内置命令
const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({
name: command.name,
description: command.description,
}));
// 第 2 层:prompt template 命令
const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({
name: cmd.name,
description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
}));
// 第 3 层:extension 命令——过滤掉和内置重名的
const builtinCommandNames = new Set(slashCommands.map((c) => c.name));
const extensionCommands: SlashCommand[] = this.session.extensionRunner
.getRegisteredCommands()
.filter((cmd) => !builtinCommandNames.has(cmd.name))
.map((cmd) => ({
name: cmd.invocationName,
description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
getArgumentCompletions: cmd.getArgumentCompletions,
}));
// 第 4 层:skill 命令
this.skillCommands.clear();
const skillCommandList: SlashCommand[] = [];
if (this.settingsManager.getEnableSkillCommands()) {
for (const skill of this.session.resourceLoader.getSkills().skills) {
const commandName = `skill:${skill.name}`;
this.skillCommands.set(commandName, skill.filePath);
skillCommandList.push({
name: commandName,
description: this.prefixAutocompleteDescription(skill.description, skill.sourceInfo),
});
}
}
// 合并成一个 autocomplete provider
return new CombinedAutocompleteProvider(
[...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList],
this.sessionManager.getCwd(),
this.fdPath,
);
}
四段代码按顺序构建四个数组,最后 spread 进 CombinedAutocompleteProvider。每个来源各自负责自己的格式——内置从常量映射、template 从 session 属性映射、extension 从 runner 获取后过滤、skill 动态生成。
2. 去重——内置优先
关键设计在 extension 命令的处理(interactive-mode.ts:518-526):
const builtinCommandNames = new Set(slashCommands.map((c) => c.name));
const extensionCommands = this.session.extensionRunner
.getRegisteredCommands()
.filter((cmd) => !builtinCommandNames.has(cmd.name)); // 内置优先,重名的过滤掉
如果 extension 注册了一个 /compact 命令——和内置的 /compact 重名——extension 的版本被过滤掉。这保证内置命令的行为不被意外覆盖。
如果确实需要覆盖内置命令的行为,用 Extension API 的事件机制(如 session_before_compact 事件,文章 8 讲过),而不是注册同名命令。
注意:去重只发生在 extension 层——prompt template 和 skill 不做去重。如果两个 prompt template 同名(两个 review.md),两个都会出现在列表里。如果 skill 和内置命令重名(不太可能,因为 skill 命令带 skill: 前缀),也不过滤。
3. fuzzy filter
CombinedAutocompleteProvider 接收合并后的命令列表,用户输入时做 fuzzy 匹配:
用户输入 /co
→ 匹配 /compact("co" 匹配 "compact")
→ 匹配 /copy("co" 匹配 "copy")
→ 匹配 /commands("co" 匹配 "commands")
→ 不匹配 /fork / /tree / /model
fuzzy filter 不要求连续匹配——/cm 也能匹配 /compact(c...m...pact)。这让用户不需要记住完整命令名,输入几个字符就能找到目标命令。
/model 命令的 fuzzy filter 有一个特殊实现(interactive-mode.ts:481-507)——它不仅匹配命令名,还匹配参数:
modelCommand.getArgumentCompletions = (prefix: string) => {
const models = this.session.modelRegistry.getAvailable();
const filtered = fuzzyFilter(models, prefix, (m) => `${m.id} ${m.provider}`);
return filtered.map((m) => ({
value: `${m.provider}/${m.id}`,
label: m.id,
description: m.provider,
}));
};
用户输入 /model claude → fuzzy 匹配所有包含 "claude" 的模型 → 显示候选列表。选中后填入 /model anthropic/claude-sonnet-4-20250514。这让用户不需要记住模型的完整 ID。
4. 参数补全
/model 的 getArgumentCompletions 是内置命令里唯一的参数补全。但 Extension 命令也可以提供参数补全——registerCommand 的 getArgumentCompletions 选项(文章 7 讲过):
pi.registerCommand("commands", {
description: "List available slash commands",
getArgumentCompletions: (prefix: string) => {
const sources = ["extension", "prompt", "skill"];
const filtered = sources.filter((s) => s.startsWith(prefix));
return filtered.length > 0 ? filtered.map((s) => ({ value: s, label: s })) : null;
},
handler: async (args, ctx) => { ... },
});
在 createBaseAutocompleteProvider 里,extension 命令的 getArgumentCompletions 被透传到 autocomplete 列表(interactive-mode.ts:525):
.map((cmd) => ({
name: cmd.invocationName,
description: ...,
getArgumentCompletions: cmd.getArgumentCompletions, // 透传
}))
这样 extension 命令的参数补全和内置命令走同一套 autocomplete 机制——CombinedAutocompleteProvider 统一处理。
5. 来源标注
prefixAutocompleteDescription 给命令描述加来源前缀:
private prefixAutocompleteDescription(description: string, sourceInfo: SourceInfo): string {
// 如 "[extension] Deploy to production"
// 如 "[prompt] Review PR with checklist"
return `[${sourceInfo.source}] ${description}`;
}
内置命令没有 sourceInfo——不加前缀。extension / prompt / skill 命令加 [extension] / [prompt] / [skill] 前缀。用户在 autocomplete 列表里能区分"这是 pi 内置的"和"这是第三方扩展加的"。
6. skill 命令的特殊处理
skill 命令不只是加到 autocomplete 列表——还存入 this.skillCommands Map(interactive-mode.ts:534):
this.skillCommands.set(commandName, skill.filePath);
用户选择 /skill:code-review 时,onSubmit 从 skillCommands 拿到 skill.filePath,直接 read SKILL.md 全文作为消息发给 LLM。这比文章 6 讲的"LLM 自动匹配 description 后 read"更直接——用户显式指定,不需要 LLM 判断。
skill 命令受 enableSkillCommands 配置控制(默认开启)。关闭后 skill 不会注册为命令——但 skill 的描述仍然在 system prompt 里,LLM 仍能自动触发。这是两个独立的机制:enableSkillCommands 控制"用户显式触发"的通道,disableModelInvocation 控制"LLM 自动触发"的通道(文章 6 讲过)。
四、解析与分派
用户按回车后,onSubmit(interactive-mode.ts:2491-2669)负责判断"这是命令还是普通消息"、怎么分派到对应 handler。这是一个 ~180 行的函数,核心是一长串 if-else。
1. 分派流程图
flowchart TD
A[用户按回车] --> B{text 以 / 开头?}
B -->|否| C{text 以 ! 开头?}
C -->|是| D[bash 命令<br/>! 或 !!]
C -->|否| E[普通消息<br/>发给 agent loop]
B -->|是| F{匹配内置命令?}
F -->|/settings| G1[showSettingsSelector]
F -->|/model| G2[handleModelCommand]
F -->|/compact| G3[handleCompactCommand]
F -->|/fork| G4[showUserMessageSelector]
F -->|/tree| G5[showTreeSelector]
F -->|/resume| G6[showSessionSelector]
F -->|/reload| G7[handleReloadCommand]
F -->|/quit| G8[shutdown]
F -->|其他内置| G9[对应 handler]
F -->|否| H{isExtensionCommand?}
H -->|是| I{agent 状态?}
I -->|compacting| I1[立即执行<br/>不等 compaction]
I -->|streaming| I2[走 steer 队列]
I -->|idle| I3[正常提交]
H -->|否| J{匹配 skill:xxx?}
J -->|是| K[read SKILL.md<br/>作为消息发给 LLM]
J -->|否| L{匹配 prompt template?}
L -->|是| M[展开 template 内容<br/>作为消息发给 LLM]
L -->|否| E
从"用户按回车"到最终执行路径,所有分支都覆盖。/ 开头先匹配内置命令,没匹配上检查 extension 命令,再检查 skill,最后检查 prompt template。都不匹配则作为普通消息发给 LLM。! 开头走 bash 命令路径。
2. 内置命令分派
伪代码(interactive-mode.ts:2491-2617):
function onSubmit(text):
# 逐个匹配内置命令
if text == "/settings": showSettingsSelector(); return
if text == "/model" or text.startsWith("/model "):
searchTerm = extractArgs(text, "/model")
handleModelCommand(searchTerm)
return
if text == "/export" or text.startsWith("/export "): handleExportCommand(text); return
if text == "/import" or text.startsWith("/import "): handleImportCommand(text); return
if text == "/share": handleShareCommand(); return
if text == "/copy": handleCopyCommand(); return
if text == "/name" or text.startsWith("/name "): handleNameCommand(text); return
if text == "/session": handleSessionCommand(); return
if text == "/changelog": handleChangelogCommand(); return
if text == "/hotkeys": handleHotkeysCommand(); return
if text == "/fork": showUserMessageSelector(); return
if text == "/clone": handleCloneCommand(); return
if text == "/tree": showTreeSelector(); return
if text == "/login": showOAuthSelector("login"); return
if text == "/logout": showOAuthSelector("logout"); return
if text == "/new": handleClearCommand(); return
if text == "/compact" or text.startsWith("/compact "):
customInstructions = extractArgs(text, "/compact")
handleCompactCommand(customInstructions)
return
if text == "/reload": handleReloadCommand(); return
if text == "/resume": showSessionSelector(); return
if text == "/quit": shutdown(); return
# 内置命令没匹配——继续检查 bash / extension / skill / template
每个内置命令是一个独立的 if 分支——精确匹配命令名或 startsWith 匹配带参数的命令(如 /model claude / /compact 用中文摘要)。匹配后调对应的 handler,然后 return——不继续检查后续分支。
两种匹配模式:
- 无参数命令:
text === "/fork"精确匹配 - 有参数命令:
text.startsWith("/compact ")匹配命令名 + 空格 + 参数。参数从text里 slice 出来
3. extension 命令特殊路径
内置命令没匹配上时,检查是否是 extension 命令(interactive-mode.ts:2637-2665)。
isExtensionCommand(interactive-mode.ts:3778-3786)检查命令名是否在 extension runner 里注册:
private isExtensionCommand(text: string): boolean {
if (!text.startsWith("/")) return false;
const commandName = text.indexOf(" ") === -1
? text.slice(1) // /deploy → "deploy"
: text.slice(1, text.indexOf(" ")); // /deploy prod → "deploy"
return !!this.session.extensionRunner.getCommand(commandName);
}
确认是 extension 命令后,根据 agent 当前状态走不同路径:
# compaction 时——extension 命令立即执行
if session.isCompacting:
if isExtensionCommand(text):
session.prompt(text) # 立即执行——不等 compaction 完成
else:
queueCompactionMessage(text) # 普通消息排队等待
return
# streaming 时——走 steer 队列
if session.isStreaming:
session.prompt(text, { streamingBehavior: "steer" })
return
# 正常提交
onInputCallback(text) # → session.prompt(text)
三条路径的差异:
compaction 时立即执行:agent 正在做 compaction(isCompacting),普通消息排队等待,但 extension 命令立即执行。因为 extension 命令不依赖 context——它调的是本地代码,不需要等 compaction 重建 context。这是"原子操作"特性的体现——compaction 是 context 压缩,extension 命令不碰 context,两者互不干扰。
streaming 时走 steer:agent 正在 streaming(isStreaming),extension 命令走 session.prompt(text, { streamingBehavior: "steer" })——命令注入到 steering 队列(文章 5 讲过),agent 在下一轮处理。这不是"立即执行"——extension 命令要等当前 LLM 回复完才执行。
正常提交:不在 compaction 也不在 streaming 时,走 onInputCallback(text) → session.prompt(text)。session.prompt 内部检查是否是 extension 命令,是的话调 extension runner 的 command handler(第 5 章讲)。
4. bash 命令
! 开头的输入不走 slash 命令系统——它是 bash 命令(interactive-mode.ts:2620-2635):
if text.startsWith("!"):
isExcluded = text.startsWith("!!") # !! 不记入 context
command = text.slice(isExcluded ? 2 : 1)
if session.isBashRunning:
showWarning("A bash command is already running")
return
handleBashCommand(command, isExcluded)
!ls 执行 ls 并把结果记入 context(LLM 能看到)。!!ls 执行 ls 但不记入 context(用户自己看结果,不给 LLM)。
这和 slash 命令是不同的交互——! 是快捷执行 bash,/ 是框架命令。两者都不经过 agent loop(! 走 handleBashCommand,不是 LLM 调用 bash 工具),但 ! 的结果可以选择性给 LLM 看。
五、命令执行
分派到对应路径后,三条执行路径各自不同:内置 handler 调框架方法、extension handler 调扩展代码、prompt template 展开成消息。
1. 内置命令 handler
以 /compact 为例(interactive-mode.ts:2582-2586):
if (text === "/compact" || text.startsWith("/compact ")) {
const customInstructions = text.startsWith("/compact ")
? text.slice(9).trim()
: undefined;
this.editor.setText("");
await this.handleCompactCommand(customInstructions);
return;
}
handleCompactCommand 内部调 AgentHarness.compact(customInstructions)——文章 8 讲过的完整 compaction 流程。customInstructions 从命令参数提取:/compact 用中文摘要 → customInstructions = "用中文摘要"。
每个内置命令调对应的框架方法:
| 命令 | handler | 调什么 |
|---|---|---|
/compact | handleCompactCommand | AgentHarness.compact() |
/fork | showUserMessageSelector | 让用户选 fork 点 → SessionManager.forkFrom() |
/tree | showTreeSelector | 让用户选分支 → session.setLeafId() |
/resume | showSessionSelector | 让用户选 session → setSessionFile() |
/new | handleClearCommand | SessionManager.newSession() |
/reload | handleReloadCommand | AgentSession.reload()(文章 7 讲过) |
/model | handleModelCommand | session.setModel() |
/quit | shutdown | 停止 agent |
内置 handler 的特点:直接调框架方法,不经过 LLM。handler 可能先弹 UI(如 /fork 弹出选择器让用户选 fork 点),拿到用户选择后调框架方法。
2. Extension 命令 handler
Extension 命令的执行链路和内置命令不同——handler 在 extension 里定义,不在 interactive-mode.ts 里。
session.prompt(text) 内部检查是否是 extension 命令,是的话调 extension runner:
session.prompt("/deploy production")
→ isExtensionCommand? → 是
→ extensionRunner.getCommand("deploy")
→ command.handler("production", ctx)
→ handler 执行 extension 代码
handler 签名(文章 7 讲过):
pi.registerCommand("deploy", {
description: "Deploy to production",
handler: async (args: string, ctx: ExtensionContext) => {
// args = "production"
// ctx 提供 UI 原语(notify / select / prompt)
const env = args;
const { stdout } = await pi.exec("npm", ["run", "deploy", "--", `--env=${env}`]);
ctx.ui.notify(`Deployed to ${env}`, "info");
},
});
handler 拿到两个参数:
args:命令名之后的文本。/deploy production→args = "production"ctx:ExtensionContext——提供ctx.ui.notify/ctx.ui.select/ctx.ui.prompt等 UI 原语(文章 7 讲过)
Extension handler 和内置 handler 的关键区别:
| 内置 handler | Extension handler | |
|---|---|---|
| 在哪定义 | interactive-mode.ts 的 onSubmit | extension 的 registerCommand |
| 参数 | 从 text 手动提取 | args 参数自动传入 |
| UI 原语 | 直接调 this.showStatus / this.editor 等 | 通过 ctx.ui 接口 |
| 能调的 API | 直接访问 this.session / this.sessionManager | 通过 pi 实例(ExtensionAPI) |
Extension handler 受限于 ExtensionAPI 的接口表面——不能直接访问 interactive-mode 的内部状态。这是封装边界——extension 只能通过 pi 提供的 API 操作 agent。
3. Prompt template 展开
Prompt template 命令的执行最简单——把 .md 文件内容作为用户消息发给 LLM:
用户输入 /review
→ session.promptTemplates.find(cmd => cmd.name == "review")
→ 拿到 template 的 content
→ 替换 $ARGUMENTS(如果有)
→ 作为 user 消息发给 agent loop
→ LLM 收到 template 内容作为 prompt
没有 handler、没有本地逻辑——纯文本替换。prompt template 的价值是预定义常用 prompt:
<!-- .pi/prompts/review.md -->
请审查当前 git diff 的代码变更,检查:
1. 命名规范
2. 错误处理
3. 测试覆盖
按严重程度分级输出审查报告。
用户输入 /review → pi 把上面这段文本作为 user 消息发给 LLM → agent 开始审查。不用每次手打这段 prompt。
$ARGUMENTS 占位符支持参数注入:
<!-- .pi/prompts/fix.md -->
请修复文件 $ARGUMENTS 中的 lint 错误。
用户输入 /fix src/index.ts → $ARGUMENTS 替换成 src/index.ts → LLM 收到"请修复文件 src/index.ts 中的 lint 错误"。
4. 三条路径对比
| 内置 handler | Extension handler | Prompt template | |
|---|---|---|---|
| 执行什么 | 框架方法 | extension 代码 | 文本替换 + 发给 LLM |
| 经过 LLM? | 不经过 | 可能(handler 可以调 pi.sendMessage) | 经过 |
| 参数 | 从 text 提取 | args 参数 | $ARGUMENTS 占位符 |
| 能调 UI? | 直接调 | 通过 ctx.ui | 不能 |
| 典型用途 | 框架操作(compact / fork / switch) | 自定义工作流(deploy / lint) | 预定义 prompt |
六、Q&A
Q1:如何注册新的命令?
取决于命令属于哪一层。
Extension 命令——最常用的注册方式,通过 pi.registerCommand:
// .pi/extensions/deploy.ts
export default function (pi: ExtensionAPI) {
pi.registerCommand("deploy", {
description: "Deploy to production",
getArgumentCompletions: (prefix) => {
const envs = ["production", "staging", "dev"];
return envs.filter(e => e.startsWith(prefix)).map(e => ({ value: e, label: e }));
},
handler: async (args, ctx) => {
const env = args || "production";
const { stdout, code } = await pi.exec("npm", ["run", "deploy", "--", `--env=${env}`]);
if (code === 0) {
ctx.ui.notify(`Deployed to ${env}`, "info");
} else {
ctx.ui.notify(`Deploy failed: ${stdout}`, "error");
}
},
});
}
注册后自动出现在 autocomplete 列表里,用户输入 /deploy 触发。
Prompt template——最简单,不需要写代码。在 .pi/prompts/ 下放一个 .md 文件,文件名就是命令名:
.pi/prompts/
└── review.md → /review 命令
Skill 命令——自动注册。在 .pi/skills/ 下放一个 SKILL.md,自动注册 /skill:{name} 命令。
内置命令——不能注册。BUILTIN_SLASH_COMMANDS 是硬编码常量,第三方不能往里加。如果需要"框架级"的命令行为,用 Extension 命令 + 事件机制实现。
三种可注册方式对比:
| 方式 | 需要写代码? | 注册方法 | 能调 UI? | 能调框架 API? | 典型用途 |
|---|---|---|---|---|---|
| Extension 命令 | 是(TypeScript) | pi.registerCommand | 能(ctx.ui) | 能(pi.exec / pi.sendMessage 等) | 自定义工作流 |
| Prompt template | 否(只写 Markdown) | 放 .pi/prompts/xxx.md | 不能 | 不能 | 预定义 prompt |
| Skill 命令 | 否(只写 SKILL.md) | 放 .pi/skills/xxx/SKILL.md | 不能 | 不能 | 显式触发 skill |
选择原则:需要执行代码 → Extension 命令;只需要预定义 prompt → Prompt template;只想显式触发 skill → Skill 命令。
Q2:slash 命令和 LLM 交互吗?
大部分不交互,但有例外。
不交互的——内置命令:/compact / /fork / /tree / /new / /reload / /quit / /model / /settings 等——纯本地执行,不经过 LLM。这些是原子操作,调框架方法完成。
和 LLM 交互的——三种路径:
| 来源 | 和 LLM 交互? | 谁决定 |
|---|---|---|
| 内置命令 | 不交互(大部分) | 框架决定——调本地方法 |
| Prompt template | 交互 | 框架决定——展开后必然发给 LLM |
| Skill 命令 | 交互 | 框架决定——read 后必然发给 LLM |
| Extension 命令 | 可选 | handler 决定——可以纯本地,也可以调 pi.sendUserMessage |
核心原则:slash 命令的分派永远是本地的(不经过 LLM 判断"这是不是命令"),但命令的执行可能和 LLM 交互——取决于命令来源和 handler 实现。
Q3:如何让新注册的命令和 LLM 交互?
两种方式。
方式 1——pi.sendUserMessage:handler 里调 pi.sendUserMessage,消息发给 LLM,触发 agent loop:
// .pi/extensions/translate.ts
export default function (pi: ExtensionAPI) {
pi.registerCommand("translate", {
description: "Translate text to English",
handler: async (args, ctx) => {
pi.sendUserMessage(`请将以下文本翻译成英文:${args}`);
},
});
}
用户输入 /translate 你好世界 → handler 调 pi.sendUserMessage → agent loop 开始 → LLM 回复 "Hello World"。
方式 2——Prompt template:不需要写代码,放一个 .md 文件:
<!-- .pi/prompts/translate.md -->
请将以下文本翻译成英文:$ARGUMENTS
用户输入 /translate 你好世界 → $ARGUMENTS 替换成 你好世界 → 发给 LLM。
两种方式效果一样——都是"快捷发消息"。区别是 Extension 命令的 handler 可以在发消息前后加逻辑(如先验证参数、发完消息后记录日志),prompt template 是纯文本替换不能加逻辑。
七、下一章预告
下一篇文章将进入 pi coding-agent 的配置系统——SettingsManager 管理 ~40 个配置项(模型/行为/资源路径/终端)、ModelRegistry 管理模型列表和 provider 注册、ResourceLoader 加载 extensions/skills/prompts/themes。三层配置(全局 / 项目级 / CLI 参数)怎么 deep merge、模型怎么发现和注册、资源怎么按配置加载——这是 pi 启动时"读什么配置、加载什么资源、用什么模型"的完整机制。