/** * agent-detail.ts — Agent detail card view with actions. * * Shows full agent configuration and offers context-appropriate actions * (Edit, Disable, Delete, Eject, Reset to default) based on agent type and source. */ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; import { getAgentDir } from "@mariozechner/pi-coding-agent"; import { getAgentConfig } from "../agent-types.js"; import { loadCustomAgents } from "../custom-agents.js"; import { getModelLabel } from "../model-resolver.js"; import type { AgentConfig } from "../types.js"; import { showAgentEditor } from "./agent-editor.js"; // ---- Path Helpers ---- const projectAgentsDir = () => join(process.cwd(), ".pi", "agents"); const personalAgentsDir = () => join(getAgentDir(), "agents"); /** * Find the file path for an agent by name, checking project then personal dirs. */ export function findAgentFile(name: string): { path: string; location: "project" | "personal" } | undefined { const projectPath = join(projectAgentsDir(), `${name}.md`); if (existsSync(projectPath)) return { path: projectPath, location: "project" }; const personalPath = join(personalAgentsDir(), `${name}.md`); if (existsSync(personalPath)) return { path: personalPath, location: "personal" }; return undefined; } /** * Reload custom agents from disk (calls loadCustomAgents + re-register). */ function reloadAgents(): void { // loadCustomAgents is a side-effect function; we call it to refresh the in-memory registry // The registry is populated by registerAgents() which is called at startup. // For now, trigger a re-read (the registry auto-updates via file watcher). const { registerAgents } = require("../agent-types.js"); const agents = loadCustomAgents(process.cwd()); registerAgents(agents); } /** * Show agent detail card with config info and action menu. * * @param ctx Extension command context * @param name Agent name to show details for */ export async function showAgentDetail( ctx: ExtensionCommandContext, name: string, ): Promise { const cfg = getAgentConfig(name); if (!cfg) { ctx.ui.notify(`Agent config not found for "${name}".`, "warning"); return; } const file = findAgentFile(name); const isDefault = cfg.isDefault === true; const disabled = cfg.enabled === false; // Show agent info const info = buildInfoString(name, cfg); ctx.ui.notify(info, "info"); // Build action menu const action = await showActionMenu(ctx, name, cfg, isDefault, disabled, !!file); if (!action) return; await executeAction(ctx, name, cfg, file, action); } /** * Build the info string for the agent notification. */ function buildInfoString(name: string, cfg: AgentConfig): string { const model = getModelLabel(cfg.model ?? "inherit"); const tools = cfg.builtinToolNames?.join(", ") ?? "all"; const source = cfg.source ?? (cfg.isDefault ? "built-in" : "unknown"); const promptMode = cfg.promptMode ?? "replace"; const memory = cfg.memory ?? "none"; const thinking = cfg.thinking ?? "inherit"; const maxTurns = cfg.maxTurns ? String(cfg.maxTurns) : "unlimited"; const extensions = cfg.extensions === false ? "none" : Array.isArray(cfg.extensions) ? cfg.extensions.join(", ") : "inherit all"; const skills = cfg.skills === false ? "none" : Array.isArray(cfg.skills) ? cfg.skills.join(", ") : "inherit all"; return [ `Agent: ${name}`, ` Source: ${source === "global" ? "Personal (~/.pi/agent/agents/)" : source === "project" ? "Project (.pi/agents/)" : "Built-in"}`, ` Model: ${model}`, ` Tools: ${tools}`, ` Prompt: ${promptMode}`, ` Memory: ${memory}`, ` Thinking: ${thinking}`, ` Max turns: ${maxTurns}`, ` Extensions: ${extensions}`, ` Skills: ${skills}`, ].join("\n"); } /** * Show the context-appropriate action menu for an agent. */ async function showActionMenu( ctx: ExtensionCommandContext, name: string, _cfg: AgentConfig, isDefault: boolean, disabled: boolean, hasFile: boolean, ): Promise { let menuOptions: string[]; if (disabled && hasFile) { menuOptions = isDefault ? ["Enable", "Edit", "Reset to default", "Delete", "Back"] : ["Enable", "Edit", "Delete", "Back"]; } else if (isDefault && !hasFile) { menuOptions = ["Eject (export as .md)", "Disable", "Back"]; } else if (isDefault && hasFile) { menuOptions = ["Edit", "Disable", "Reset to default", "Delete", "Back"]; } else { menuOptions = ["Edit", "Disable", "Delete", "Back"]; } return ctx.ui.select(name, menuOptions); } /** * Execute the chosen action. */ async function executeAction( ctx: ExtensionCommandContext, name: string, cfg: AgentConfig, file: { path: string; location: "project" | "personal" } | undefined, action: string, ): Promise { if (action === "Back") return; if (action === "Edit" && file) { await showAgentEditor(ctx, name); return; } if (action === "Delete") { if (file) { const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${name} from ${file.location}?`); if (confirmed) { unlinkSync(file.path); reloadAgents(); ctx.ui.notify(`Deleted ${name}`, "info"); } } return; } if (action === "Reset to default" && file) { const confirmed = await ctx.ui.confirm("Reset to default", `Delete override file and restore embedded default?`); if (confirmed) { unlinkSync(file.path); reloadAgents(); ctx.ui.notify(`Restored default ${name}`, "info"); } return; } if (action.startsWith("Eject")) { await ejectAgent(ctx, name, cfg); return; } if (action === "Disable") { await disableAgent(ctx, name, file); return; } if (action === "Enable") { await enableAgent(ctx, name, file); return; } } /** * Eject a default agent: write its embedded config as a .md file. */ async function ejectAgent(ctx: ExtensionCommandContext, name: string, cfg: AgentConfig): Promise { const location = await ctx.ui.select("Choose location", [ "Project (.pi/agents/)", `Personal (${personalAgentsDir()})`, ]); if (!location) return; const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir(); mkdirSync(targetDir, { recursive: true }); const targetPath = join(targetDir, `${name}.md`); if (existsSync(targetPath)) { const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`); if (!overwrite) return; } const fmFields: string[] = []; fmFields.push(`description: ${cfg.description}`); if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`); fmFields.push(`tools: ${cfg.builtinToolNames?.join(", ") || "all"}`); if (cfg.model) fmFields.push(`model: ${cfg.model}`); if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`); if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`); fmFields.push(`prompt_mode: ${cfg.promptMode}`); if (cfg.extensions === false) fmFields.push("extensions: false"); else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`); if (cfg.skills === false) fmFields.push("skills: false"); else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`); if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`); if (cfg.inheritContext) fmFields.push("inherit_context: true"); if (cfg.runInBackground) fmFields.push("run_in_background: true"); if (cfg.isolated) fmFields.push("isolated: true"); if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`); if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`); const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt ?? ""}\n`; writeFileSync(targetPath, content, "utf-8"); reloadAgents(); ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info"); } /** * Disable an agent: set enabled: false in its .md or create stub. */ async function disableAgent( ctx: ExtensionCommandContext, name: string, file: { path: string; location: "project" | "personal" } | undefined, ): Promise { if (file) { const content = readFileSync(file.path, "utf-8").replace(/\r\n/g, "\n"); if (/\benabled:\s*false\b/m.test(content)) { ctx.ui.notify(`${name} is already disabled.`, "info"); return; } const updated = content.replace(/^---\n/, "---\nenabled: false\n"); writeFileSync(file.path, updated, "utf-8"); reloadAgents(); ctx.ui.notify(`Disabled ${name}`, "info"); return; } // No file — create a stub const location = await ctx.ui.select("Choose location", [ "Project (.pi/agents/)", `Personal (${personalAgentsDir()})`, ]); if (!location) return; const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir(); mkdirSync(targetDir, { recursive: true }); const targetPath = join(targetDir, `${name}.md`); writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8"); reloadAgents(); ctx.ui.notify(`Disabled ${name}`, "info"); } /** * Enable a disabled agent: remove enabled: false from frontmatter. */ async function enableAgent( ctx: ExtensionCommandContext, name: string, file: { path: string; location: "project" | "personal" } | undefined, ): Promise { if (!file) return; const content = readFileSync(file.path, "utf-8").replace(/\r\n/g, "\n"); const updated = content.replace(/^(---\n)enabled: false\n/, "$1"); // If the file was just a stub, delete it to restore the default const normalized = updated.trim(); if (normalized === "---\n---" || normalized === "---\n---\n") { unlinkSync(file.path); reloadAgents(); ctx.ui.notify(`Enabled ${name} (restored default)`, "info"); } else { writeFileSync(file.path, updated, "utf-8"); reloadAgents(); ctx.ui.notify(`Enabled ${name}`, "info"); } }