背景 链接到标题

Pi Coding Agent 内置斜杠命令机制,用户输入 /cmd 时优先匹配已注册的命令,不发给 LLM。命令由扩展通过 pi.registerCommand() 注册,handler 接收 ctx 参数,可调用 ctx.newSession()ctx.reload()ctx.ui 等方法。

SDK 示例自带 /commands 命令(列出所有可用命令),但直接在项目中引用时没有输出——因为默认环境未注入 UI Context。

注册命令 链接到标题

Extension 文件中通过 pi.registerCommand() 注册:

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  pi.registerCommand("mycmd", {
    description: "命令简短描述",
    handler: async (args, ctx) => {
      // args: 命令后面的参数字符串
      // ctx: ExtensionCommandContext
    },
  });
}

registerCommand 的 options 字段:

字段 类型 说明
description string 命令描述,/commands 列表展示
getArgumentCompletions function 可选,参数自动补全
handler function 命令执行函数

handlerctxExtensionCommandContext)常用方法:

方法 说明
ctx.newSession() 创建新 Session,返回 { cancelled }
ctx.reload() 热重载所有扩展
ctx.ui.notify(msg, type) 显示通知
ctx.ui.select(title, options) 弹出选择框
ctx.ui.confirm(title, message) 弹出确认框

ctx.ui 系列方法默认是 no-op(不报错也不输出),需要主动注入 UI Context 才会生效。

/commands 为什么没有输出 链接到标题

扩展 handler 执行时调用 ctx.ui.select(),但在 Electron 等自定义宿主环境中,ctx.ui 是默认的 no-op 实现:

// SDK 内部默认实现
select: async () => undefined,    // 始终返回 undefined
notify: () => {},                 // 静默空操作
confirm: async () => false,

因此 /commands 的 handler 虽然正常运行,但 select() 返回 undefined,后续 if (selected) 分支永不进入,用户看不到任何输出。

注入 UI Context 链接到标题

通过 session.bindExtensions() 注入自定义 uiContext,将渲染进程的选择结果通过 Promise 回传:

main.js — 注入 uiContext 链接到标题

const pendingUI = new Map();
let uiId = 0;

piSession.bindExtensions({
  mode: 'tui',
  uiContext: {
    select: async (title, options) =>
      new Promise((resolve) => {
        const id = ++uiId;
        pendingUI.set(id, resolve);
        sendToRenderer('pi:event', { type: 'ui:select', id, title, options });
      }),

    confirm: async (title, message) =>
      new Promise((resolve) => {
        const id = ++uiId;
        pendingUI.set(id, resolve);
        sendToRenderer('pi:event', { type: 'ui:confirm', id, title, message });
      }),

    notify: (message, type) => {
      sendToRenderer('pi:event', { type: 'ui:notify', message, notifyType: type || 'info' });
    },

    // 其余方法 no-op
    input: async () => undefined,
    setStatus: () => {},
    // ...
  },
});

// 接收渲染进程的响应,只注册一次
ipcMain.handle('ui:response', (_event, { id, value }) => {
  const resolve = pendingUI.get(id);
  if (resolve) { resolve(value); pendingUI.delete(id); }
});

index.html — 渲染进程响应 链接到标题

unsubscribe = window.pi.onEvent((event) => {
  switch (event.type) {
    case 'ui:select':
      showSelectModal(event.id, event.title, event.options);
      break;
    case 'ui:confirm':
      showConfirmModal(event.id, event.title, event.message);
      break;
    case 'ui:notify':
      showNotify(event.message, event.notifyType);
      break;
  }
});

// 用户选择后回传
window.pi.sendUIResponse({ id, value: selectedOption });

preload.js 链接到标题

contextBridge.exposeInMainWorld('pi', {
  // ... 其他方法
  sendUIResponse: (data) => ipcRenderer.invoke('ui:response', data),
});

/commands 可用后的效果 链接到标题

UI Context 注入后,ctx.ui.select() 真正弹出选择框:

┌──────────────────────────┐
│ 可用命令                  │
├──────────────────────────┤
│ --- 扩展 ---              │
│ /commands - 列出所有可用… │
│ /clear - 清空当前对话      │
│ /new - 新建 session…      │
│ --- 提示词 ---            │
│ /template-name            │
│ --- 技能 ---              │
│ /skill:name               │
└──────────────────────────┘

选择命令后还可查看来源路径(ctx.ui.confirm())。

实现 /new 与 /clear 链接到标题

两个命令均在扩展文件中注册:

// /clear:清空消息列表
pi.registerCommand("clear", {
  description: "清空当前对话",
  handler: async (_args, ctx) => {
    ctx.ui.clearMessages();
  },
});

// /new:重建 Session 并清空
pi.registerCommand("new", {
  description: "新建 session 并清空对话",
  handler: async (_args, ctx) => {
    const result = await ctx.newSession();
    if (!result.cancelled) ctx.ui.clearMessages();
  },
});

ctx.ui.clearMessages() 是注入的自定义方法,发送 ui:clear 事件到渲染进程清空 messagesEl.innerHTML

踩坑汇总 链接到标题

1. /commands 无输出 链接到标题

缺少 UI Context 注入,ctx.ui.select() 静默返回 undefined

2. 重复注册 ipcMain.handle 链接到标题

ipcMain.handle('ui:response')session:create 内部,每次重连(如配置 proxy 后保存)会再次注册同一 channel,报 Attempted to register a second handler

解决:将 pendingUI map 提升到模块级别,ipcMain.handle('ui:response') 在模块顶层只注册一次,session:create 内只 pendingUI = new Map() 重置。

3. bindExtensions 重建 Session 后要 reset pendingUI 链接到标题

Session 重建时旧请求的 Promise 永远不 resolve,导致内存泄漏。在 piSession.bindExtensions() 之前 pendingUI = new Map() 即可。

参考 链接到标题