从零开始拆解Pi系列——(10)slash 命令系统

2 阅读12分钟

一、引言:为什么需要命令系统

前九篇文章讲了 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,如 /compactAgentHarness.compact()/forkSessionManager.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_COMMANDSslash-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.tsonSubmit 里(第 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
Skillskill:{skill-name}/skill:code-review

如果 extension 命令和内置命令重名,内置命令优先——extension 命令在合并时被过滤(第 3 章讲)。

三、自动补全

四层来源的命令最终要合并成一个 autocomplete 列表——用户输入 / 时弹出候选,输入 /co 时 fuzzy 匹配。这靠 createBaseAutocompleteProviderinteractive-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. 参数补全

/modelgetArgumentCompletions 是内置命令里唯一的参数补全。但 Extension 命令也可以提供参数补全——registerCommandgetArgumentCompletions 选项(文章 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 时,onSubmitskillCommands 拿到 skill.filePath,直接 read SKILL.md 全文作为消息发给 LLM。这比文章 6 讲的"LLM 自动匹配 description 后 read"更直接——用户显式指定,不需要 LLM 判断。

skill 命令受 enableSkillCommands 配置控制(默认开启)。关闭后 skill 不会注册为命令——但 skill 的描述仍然在 system prompt 里,LLM 仍能自动触发。这是两个独立的机制:enableSkillCommands 控制"用户显式触发"的通道,disableModelInvocation 控制"LLM 自动触发"的通道(文章 6 讲过)。

四、解析与分派

用户按回车后,onSubmitinteractive-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)。

isExtensionCommandinteractive-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调什么
/compacthandleCompactCommandAgentHarness.compact()
/forkshowUserMessageSelector让用户选 fork 点 → SessionManager.forkFrom()
/treeshowTreeSelector让用户选分支 → session.setLeafId()
/resumeshowSessionSelector让用户选 session → setSessionFile()
/newhandleClearCommandSessionManager.newSession()
/reloadhandleReloadCommandAgentSession.reload()(文章 7 讲过)
/modelhandleModelCommandsession.setModel()
/quitshutdown停止 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 productionargs = "production"
  • ctxExtensionContext——提供 ctx.ui.notify / ctx.ui.select / ctx.ui.prompt 等 UI 原语(文章 7 讲过)

Extension handler 和内置 handler 的关键区别:

内置 handlerExtension handler
在哪定义interactive-mode.tsonSubmitextension 的 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. 三条路径对比

内置 handlerExtension handlerPrompt 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 启动时"读什么配置、加载什么资源、用什么模型"的完整机制。