"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { sendAgentCommand } from "@/lib/agent-client"; import { useIsMobile } from "@/hooks/useIsMobile"; import type { PluginPackageInfo, PluginsResponse } from "@/lib/api-types"; import { useI18n } from "@/hooks/useI18n"; type PluginScope = PluginPackageInfo["scope"]; type PluginAction = "install" | "remove" | "update" | "disable" | "enable"; function shortenPath(path: string): string { return path.replace(/^\/(?:Users|home)\/[^/]+/, "~"); } function normalizePluginSourceInput(value: string): string { const match = value.trim().match(/^\$?\s*pi\s+install\s+(\S+)\s*$/); return match?.[1] ?? value; } function packageKey(pkg: Pick): string { return `${pkg.scope}\0${pkg.source}`; } function resourceSummary(pkg: PluginPackageInfo, t: ReturnType["t"]): string { if (pkg.disabled) return t("i18n.disabled"); const parts = [ pkg.counts.extensions ? t("i18n.resourceCount", { count: pkg.counts.extensions, label: t("i18n.extensionShort") }) : "", pkg.counts.skills ? t("i18n.resourceCount", { count: pkg.counts.skills, label: t("i18n.skillShort") }) : "", pkg.counts.prompts ? t("i18n.resourceCount", { count: pkg.counts.prompts, label: t("i18n.promptShort") }) : "", pkg.counts.themes ? t("i18n.resourceCount", { count: pkg.counts.themes, label: t("i18n.themeShort") }) : "", ].filter(Boolean); return parts.length ? parts.join(" · ") : t("i18n.noResources"); } function versionSummary(pkg: PluginPackageInfo, t: ReturnType["t"]): string { const parts = []; if (pkg.version) parts.push(t("i18n.installedVersion", { version: pkg.version })); if (pkg.configuredVersion) parts.push(t("i18n.configuredVersion", { version: pkg.configuredVersion })); return parts.length ? parts.join(" · ") : t("i18n.unknown"); } function installLocation(scope: PluginScope, cwd: string): string { return scope === "project" ? `${shortenPath(cwd)}/.pi/agent/{npm,git}` : "~/.pi/agent/{npm,git}"; } function findInstalledPackage( packages: PluginPackageInfo[], source: string, scope: PluginScope, ): PluginPackageInfo | undefined { const trimmed = source.trim(); const withoutNpmPrefix = trimmed.startsWith("npm:") ? trimmed.slice(4) : trimmed; return packages.find((pkg) => pkg.scope === scope && pkg.source === trimmed) ?? packages.find((pkg) => pkg.scope === scope && pkg.source === `npm:${withoutNpmPrefix}`) ?? packages.find((pkg) => pkg.scope === scope && pkg.source.endsWith(trimmed)); } function statusColor(status: PluginPackageInfo["status"]): string { if (status === "loaded") return "var(--accent)"; if (status === "installed") return "#f59e0b"; if (status === "disabled") return "var(--text-dim)"; return "#ef4444"; } function ResourceList({ pkg }: { pkg: PluginPackageInfo }) { const { t } = useI18n(); const groups = ([ ["extension", t("i18n.extensions")], ["skill", t("i18n.skills")], ["prompt", t("i18n.prompts")], ["theme", t("i18n.themes")], ] as const) .map(([kind, label]) => ({ kind, label, resources: pkg.resources.filter((resource) => resource.kind === kind), })) .filter((group) => group.resources.length > 0); if (groups.length === 0) { return (
{pkg.disabled ? t("i18n.packageDisabled") : t("i18n.noResolvedResources")}
); } return (
{groups.map((group, groupIndex) => (
{group.label}
{group.resources.map((resource) => (
{resource.name}
{resource.relativePath}
))}
))}
); } function ScopeTag({ scope }: { scope: PluginScope }) { return ( {scope} ); } function buttonStyle(disabled?: boolean, danger?: boolean): React.CSSProperties { return { padding: "6px 12px", background: danger ? "rgba(239,68,68,0.08)" : "none", border: "1px solid var(--border)", borderRadius: 6, color: danger ? "#ef4444" : "var(--text-muted)", cursor: disabled ? "not-allowed" : "pointer", fontSize: 12, opacity: disabled ? 0.5 : 1, }; } function Toggle({ enabled, loading, onToggle, label, }: { enabled: boolean; loading: boolean; onToggle: () => void; label: string; }) { return ( ); } function SegmentedScope({ value, projectResourcesLoaded, onChange, }: { value: PluginScope; projectResourcesLoaded: boolean; onChange: (scope: PluginScope) => void; }) { const { t } = useI18n(); return (
{(["global", "project"] as PluginScope[]).map((scope) => { const active = value === scope; const disabled = scope === "project" && !projectResourcesLoaded; return ( ); })}
); } function AddPluginPanel({ cwd, source, scope, projectResourcesLoaded, busy, actionError, onSourceChange, onScopeChange, onInstall, }: { cwd: string; source: string; scope: PluginScope; projectResourcesLoaded: boolean; busy: boolean; actionError: string | null; onSourceChange: (value: string) => void; onScopeChange: (scope: PluginScope) => void; onInstall: () => void; }) { const { t } = useI18n(); const inputRef = useRef(null); const examples = ["npm:@scope/pi-plugin", "git:https://github.com/user/repo", "/absolute/path/to/plugin"]; useEffect(() => { inputRef.current?.focus(); }, []); return (
{t("i18n.addPlugin")}
pi.dev/packages
{installLocation(scope, cwd)}
onSourceChange(e.target.value)} onPaste={(e) => { const pasted = e.clipboardData.getData("text"); const normalized = normalizePluginSourceInput(pasted); if (normalized === pasted) return; e.preventDefault(); onSourceChange(normalized); }} onBlur={(e) => onSourceChange(normalizePluginSourceInput(e.currentTarget.value))} placeholder="npm:@scope/package" style={{ width: "100%", height: 36, padding: "0 11px", border: "1px solid var(--border)", borderRadius: 6, background: "var(--bg-panel)", color: "var(--text)", fontFamily: "var(--font-mono)", fontSize: 13, outline: "none", }} onKeyDown={(e) => { if (e.key === "Enter" && source.trim() && !busy) onInstall(); }} />
Examples
{examples.map((example) => ( ))}
{actionError && (
{actionError}
)}
); } function PackageDetail({ pkg, cwd, busyKey, actionError, actionMessage, sessionId, onAction, onReloadSession, }: { pkg: PluginPackageInfo; cwd: string; busyKey: string | null; actionError: string | null; actionMessage: string | null; sessionId: string | null; onAction: (action: PluginAction, pkg: PluginPackageInfo) => void; onReloadSession: () => void; }) { const { t } = useI18n(); const key = packageKey(pkg); const busy = busyKey?.endsWith(key) ?? false; const reloadBusy = busyKey === "reload"; const enabled = !pkg.disabled; return (
onAction(pkg.disabled ? "enable" : "disable", pkg)} label={pkg.disabled ? t("i18n.enablePackage") : t("i18n.disablePackage")} /> {pkg.disabled ? ( {t("i18n.disabled")} ) : pkg.filtered && ( {t("i18n.filtered")} )} {pkg.source}
{t("i18n.status")}
{pkg.status}
{t("i18n.version")}
{versionSummary(pkg, t)}
{t("i18n.package")}
{pkg.packageName ?? t("i18n.unknown")}
{t("i18n.resources")}
{resourceSummary(pkg, t)}
{t("i18n.installedPath")}
{pkg.installedPath ? shortenPath(pkg.installedPath) : t("i18n.notFound")}
{t("i18n.cwd")}
{shortenPath(cwd)}
{t("i18n.resolvedResources")}
{actionMessage && (
{actionMessage}
)} {actionError && (
{actionError}
)}
); } export function PluginsConfig({ cwd, sessionId, onClose, onReloaded, }: { cwd: string; sessionId: string | null; onClose: () => void; onReloaded?: () => void; }) { const isMobile = useIsMobile(); const { t } = useI18n(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selected, setSelected] = useState(null); const [addMode, setAddMode] = useState(false); const [installSource, setInstallSource] = useState(""); const [installScope, setInstallScope] = useState("global"); const [busyKey, setBusyKey] = useState(null); const [actionError, setActionError] = useState(null); const [actionMessage, setActionMessage] = useState(null); const packages = useMemo(() => data?.packages ?? [], [data?.packages]); const selectedPackage = packages.find((pkg) => packageKey(pkg) === selected) ?? null; const projectResourcesLoaded = data?.projectResourcesLoaded ?? true; const groupedPackages = useMemo(() => { return (["project", "global"] as PluginScope[]) .map((scope) => ({ scope, packages: packages.filter((pkg) => pkg.scope === scope) })) .filter((group) => group.packages.length > 0); }, [packages]); const loadPlugins = useCallback(async () => { setLoading(true); setError(null); try { const res = await fetch(`/api/plugins?cwd=${encodeURIComponent(cwd)}`); const next = (await res.json()) as PluginsResponse & { error?: string }; if (!res.ok || next.error) throw new Error(next.error ?? `HTTP ${res.status}`); setData(next); setAddMode((current) => next.packages.length === 0 || current); setSelected((current) => { if (current && next.packages.some((pkg) => packageKey(pkg) === current)) return current; return next.packages[0] ? packageKey(next.packages[0]) : null; }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } }, [cwd]); useEffect(() => { void loadPlugins(); }, [loadPlugins]); const runAction = useCallback(async (action: PluginAction, pkg: PluginPackageInfo) => { const key = packageKey(pkg); setBusyKey(`${action}:${key}`); setActionError(null); setActionMessage(null); try { const res = await fetch("/api/plugins", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, source: pkg.source, scope: pkg.scope, cwd }), }); const next = (await res.json()) as PluginsResponse & { error?: string }; if (!res.ok || next.error) throw new Error(next.error ?? `HTTP ${res.status}`); setData(next); if (action === "remove") { setSelected(next.packages[0] ? packageKey(next.packages[0]) : null); if (next.packages.length === 0) setAddMode(true); setActionMessage("Package removed."); } else { const messages: Record, string> = { install: "Package installed.", update: "Package updated.", disable: "Package disabled.", enable: "Package enabled.", }; setActionMessage(messages[action]); } } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); } finally { setBusyKey(null); } }, [cwd]); const installPlugin = useCallback(async () => { const source = normalizePluginSourceInput(installSource).trim(); if (!source) return; setInstallSource(source); const key = `${installScope}\0${source}`; setBusyKey(`install:${key}`); setActionError(null); setActionMessage(null); try { const res = await fetch("/api/plugins", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "install", source, scope: installScope, cwd }), }); const next = (await res.json()) as PluginsResponse & { error?: string }; if (!res.ok || next.error) throw new Error(next.error ?? `HTTP ${res.status}`); setData(next); const installed = findInstalledPackage(next.packages, source, installScope); setSelected(installed ? packageKey(installed) : key); setAddMode(false); setInstallSource(""); setActionMessage("Package installed."); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); } finally { setBusyKey(null); } }, [cwd, installScope, installSource]); const reloadSession = useCallback(async () => { if (!sessionId) return; setBusyKey("reload"); setActionError(null); setActionMessage(null); try { await sendAgentCommand(sessionId, { type: "reload" }); onReloaded?.(); await loadPlugins(); setActionMessage("Session reloaded."); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); } finally { setBusyKey(null); } }, [loadPlugins, onReloaded, sessionId]); const addBusy = busyKey?.startsWith("install:") ?? false; return (
{ if (e.target === e.currentTarget) onClose(); }} >
{t("common.plugins")} {shortenPath(cwd)}
{!projectResourcesLoaded && (
{t("trust.pluginsNotLoaded")}
)}
{loading ? (
Loading...
) : error ? (
{error}
) : packages.length === 0 ? (
No plugins configured
) : ( groupedPackages.map((group) => (
{group.scope}
{group.packages.map((pkg) => { const key = packageKey(pkg); const isSelected = !addMode && selected === key; return (
{ setSelected(key); setAddMode(false); setActionError(null); setActionMessage(null); }} style={{ display: "flex", alignItems: "center", gap: 7, padding: "8px 8px", borderRadius: 5, cursor: "pointer", background: isSelected ? "var(--bg-selected)" : "none", }} onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = "var(--bg-hover)"; }} onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.background = "none"; }} >
{pkg.source}
{resourceSummary(pkg, t)}
{(pkg.version || pkg.configuredVersion) && (
{versionSummary(pkg, t)}
)}
); })}
)) )}
{addMode ? ( ) : loading ? null : selectedPackage ? ( ) : (
{t("i18n.selectPackage")}
)}
{data?.diagnostics.length ? ( `${d.type}: ${d.source ? `${d.source}: ` : ""}${d.message}`).join("\n")} style={{ color: data.diagnostics.some((d) => d.type === "error") ? "#ef4444" : "#d97706" }} > {data.diagnostics.length} diagnostic{data.diagnostics.length === 1 ? "" : "s"} ) : ( {data ? `${data.totals.extensions} ext · ${data.totals.skills} skills · ${data.totals.prompts} prompts · ${data.totals.themes} themes` : ""} )}
); }