/** * pi-agent-switcher Extension * * Allows users to manually switch between agent roles in the main Pi session. * Each agent is defined via markdown files with YAML frontmatter, controlling * system prompt, tools, model, and thinking level. * * Switch via Alt+A or /agent command. * List agents via /agents command. * Reset to default via /agent reset. */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { DynamicBorder } from "@earendil-works/pi-coding-agent"; import { Container, Key, type SelectItem, SelectList, Text, Box } from "@earendil-works/pi-tui"; import { AgentStateManager } from "./agent-state"; import { discoverAgents, resolveAgent } from "./agents"; let stateManager: AgentStateManager; export default function agentSwitcherExtension(pi: ExtensionAPI) { stateManager = new AgentStateManager(); // ============================================================ // Event: before_agent_start — modify system prompt & tools // ============================================================ pi.on("before_agent_start", async (event, ctx) => { const currentAgentName = stateManager.getCurrentAgent(); if (!currentAgentName) return; const agent = resolveAgent(ctx.cwd, currentAgentName, stateManager.lastDiscovery()); if (!agent) return; // Always use prepend mode: agent's identity goes first (high-attention position), // Pi's built-in tool/guideline text is kept after as background context, // wrapped in a tag so the model clearly separates identity from environment. const systemPrompt = agent.systemPrompt + "\n\n\n" + event.systemPrompt + "\n"; // Tool set switching if (agent.tools && agent.tools.length > 0) { pi.setActiveTools(agent.tools); } // Model switching if (agent.model) { const [provider, ...rest] = agent.model.split("/"); const modelId = rest.join("/"); if (provider && modelId) { const model = ctx.modelRegistry.find(provider, modelId); if (model) { const success = await pi.setModel(model); if (!success) { ctx.ui.notify( `Agent "${agent.name}": 无法切换到模型 ${agent.model}`, "warning", ); } } } } // Thinking level switching if (agent.thinking) { pi.setThinkingLevel(agent.thinking); } return { systemPrompt, }; }); // ============================================================ // Event: session_start — restore agent state & update UI // ============================================================ pi.on("session_start", async (_event, ctx) => { // Restore agent state — find the LATEST entry (not the first) let latestState: { currentAgent?: string | null } | undefined; for (const entry of ctx.sessionManager.getBranch()) { if ( entry.type === "custom" && (entry as { customType?: string }).customType === "agent-switcher-state" ) { const data = (entry as { data?: { currentAgent?: string | null } }).data; if (data) latestState = data; } } if (latestState?.currentAgent) { stateManager.setAgent(latestState.currentAgent); // Restore session name display pi.setSessionName(`🤖 ${latestState.currentAgent}`); } else { stateManager.reset(); } // Discover available agents const discovery = discoverAgents(ctx.cwd, "both"); stateManager.setLastDiscovery(discovery); }); // ============================================================ // Shortcut: Alt+A — open agent switcher // ============================================================ pi.registerShortcut(Key.alt("a"), { description: "切换主 agent 角色", handler: async (ctx) => { if (!ctx.isIdle()) return; await openAgentSwitcher(pi, stateManager, ctx); }, }); // ============================================================ // Command: /agent — switch or open selector // ============================================================ pi.registerCommand("agent", { description: "切换主 agent 角色", getArgumentCompletions(prefix: string) { const discovery = discoverAgents(process.cwd(), "both"); return discovery.agents .filter((a) => a.name.startsWith(prefix)) .map((a) => ({ value: a.name, label: a.name, description: a.description })); }, handler: async (args, ctx) => { const agentName = args.trim(); if (!agentName) { await openAgentSwitcher(pi, stateManager, ctx); return; } if (agentName === "reset" || agentName === "default") { await resetToDefault(pi, stateManager, ctx); return; } await performSwitch(pi, stateManager, agentName, ctx); }, }); // ============================================================ // Command: /agents — list all available agents // ============================================================ pi.registerCommand("agents", { description: "列出所有可用 agent 角色", handler: async (_args, ctx) => { const discovery = discoverAgents(ctx.cwd, "both"); const currentAgent = stateManager.getCurrentAgent(); const lines = discovery.agents.map((a) => { const marker = a.name === currentAgent ? "→ " : " "; const scopeTag = a.source === "project" ? " [项目]" : ""; const modelTag = a.model ? ` (${a.model})` : ""; return `${marker}${a.name}: ${a.description}${modelTag}${scopeTag}`; }); ctx.ui.notify( `当前: ${currentAgent || "默认"}\n\n${lines.join("\n")}`, "info", ); }, }); } // ============================================================ // Core: open agent selector UI // ============================================================ async function openAgentSwitcher( pi: ExtensionAPI, stateManager: AgentStateManager, ctx: ExtensionContext, ): Promise { const discovery = discoverAgents(ctx.cwd, "both"); stateManager.setLastDiscovery(discovery); const currentAgent = stateManager.getCurrentAgent(); // Separate global and project agents const globalAgents = discovery.agents.filter((a) => a.source !== "project"); const projectAgents = discovery.agents.filter((a) => a.source === "project"); const items: SelectItem[] = []; // Global agents for (const a of globalAgents) { items.push({ value: a.name, label: a.name === currentAgent ? `✓ ${a.name}` : ` ${a.name}`, description: a.description + (a.model ? ` (${a.model})` : ""), }); } // Project agents (with separator) if (projectAgents.length > 0) { items.push({ value: "__separator__", label: "── 项目 Agent ──", description: "", }); for (const a of projectAgents) { items.push({ value: a.name, label: a.name === currentAgent ? `✓ ${a.name} [项目]` : ` ${a.name} [项目]`, description: a.description + (a.model ? ` (${a.model})` : ""), }); } } // Reset option items.push({ value: "__reset__", label: "↩ 重置为默认", description: "恢复 Pi 默认行为", }); // Build digit-to-value mapping for quick shortcuts (keys 1-9), // and prepend shortcut numbers to item labels. const shortcutMap = new Map(); { let num = 1; for (const item of items) { if (item.value === "__separator__") continue; if (num > 9) break; shortcutMap.set(num, item.value); const prefix = `[${num}] `; item.label = prefix + item.label; num++; } } const result = await ctx.ui.custom((tui, theme, _kb, done) => { const container = new Container(); // Box wrapper with subtle background to distinguish from background const box = new Box(0, 0, (s: string) => theme.bg("customMessageBg", s)); // Top border box.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); // Title box.addChild( new Text( theme.fg("accent", theme.bold("🤖 切换主 Agent")) + " " + theme.fg("dim", `(当前: ${currentAgent || "默认"})`), 1, 0, ), ); // SelectList const selectList = new SelectList(items, Math.min(items.length, 12), { selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", t), description: (t) => theme.fg("muted", t), scrollInfo: (t) => theme.fg("dim", t), noMatch: (t) => theme.fg("warning", t), }); selectList.onSelect = (item) => done(item.value); selectList.onCancel = () => done(null); box.addChild(selectList); // Help text box.addChild( new Text( theme.fg("dim", "↑↓ 导航 • 1-9 快捷选择 • enter 选择 • esc 取消 • 输入过滤"), 1, 0, ), ); // Bottom border box.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); container.addChild(box); return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data: string) => { // Quick shortcut: digit 1-9 directly selects the corresponding item if (data.length === 1 && data >= "1" && data <= "9") { const digit = Number(data); const value = shortcutMap.get(digit); if (value) { done(value); return; } } selectList.handleInput(data); tui.requestRender(); }, }; }); if (result === null || result === "__separator__") return; if (result === "__reset__") { await resetToDefault(pi, stateManager, ctx); return; } await performSwitch(pi, stateManager, result, ctx); } // ============================================================ // Core: perform agent switch // ============================================================ async function performSwitch( pi: ExtensionAPI, stateManager: AgentStateManager, agentName: string, ctx: ExtensionContext, ): Promise { const discovery = stateManager.lastDiscovery() ?? discoverAgents(ctx.cwd, "both"); const agent = discovery.agents.find((a) => a.name === agentName); if (!agent) { const available = discovery.agents.map((a) => a.name).join(", "); ctx.ui.notify( `未知 agent: "${agentName}"。可用: ${available}`, "error", ); return; } const previousAgent = stateManager.getCurrentAgent(); stateManager.setAgent(agentName); // Persist state in session pi.appendEntry("agent-switcher-state", { currentAgent: agentName }); // Show agent name inline after cwd on line 1 of footer pi.setSessionName(`🤖 ${agent.name}`); // Notify user const prevLabel = previousAgent || "默认"; ctx.ui.notify(`Agent 切换: ${prevLabel} → ${agent.name}`, "info"); } // ============================================================ // Core: reset to default agent // ============================================================ async function resetToDefault( pi: ExtensionAPI, stateManager: AgentStateManager, ctx: ExtensionContext, ): Promise { const previousAgent = stateManager.getCurrentAgent(); if (!previousAgent) { ctx.ui.notify("当前已是默认 agent", "info"); return; } stateManager.reset(); // Persist reset pi.appendEntry("agent-switcher-state", { currentAgent: null }); // Restore original session name (or clear if it was set by us) pi.setSessionName(""); // Notify user ctx.ui.notify(`Agent 重置: ${previousAgent} → 默认`, "info"); }