背景 链接到标题
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 |
命令执行函数 |
handler 的 ctx(ExtensionCommandContext)常用方法:
| 方法 | 说明 |
|---|---|
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() 即可。