/** * Pi Extensions Manager Component * * Provides UI for managing Pi extension packages, extensions, skills, prompts, and themes * stored in the global pi settings (~/.pi/agent/settings.json). * * Features: * - List configured package sources with type badges (npm/git/local) * - Add new package sources via install form * - Remove package sources from the list * - Manage top-level extension, skill, prompt, and theme path arrays * - Loading and empty states */ import "./PiExtensionsManager.css"; import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Package, Puzzle, BookOpen, FileText, Palette, Plus, X, ChevronDown, ChevronRight, RefreshCw, Trash2, } from "lucide-react"; import { fetchPiSettings, updatePiSettings, installPiPackage, reinstallFusionPiPackage, fetchPiExtensions, updatePiExtensions, type PiSettings, type PiExtensionEntry } from "../api"; import type { ToastType } from "../hooks/useToast"; import { LoadingSpinner } from "./LoadingSpinner"; interface PiExtensionsManagerProps { addToast: (message: string, type?: ToastType) => void; projectId?: string; } /** Map source value to CSS class suffix */ function getSourceClass(source: PiExtensionEntry["source"]): string { return source.replace(/-/g, "-"); } /** Get display label for extension source */ function getSourceLabel(source: PiExtensionEntry["source"]): string { const labels: Record = { "fusion-global": "Fusion Global", "pi-global": "Pi Global", "fusion-project": "Fusion Project", "pi-project": "Pi Project", package: "Package", }; return labels[source] ?? source; } /** Determine package source type from the source string */ function getPackageType(source: string): "npm" | "git" | "local" { if (source.startsWith("npm:")) return "npm"; if (source.startsWith("git:")) return "git"; return "local"; } /** Get display label for a package source (strip prefix) */ function getPackageLabel(source: string): string { return source.replace(/^(npm:|git:)/, ""); } export function PiExtensionsManager({ addToast, projectId }: PiExtensionsManagerProps) { const { t } = useTranslation("app"); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [installing, setInstalling] = useState(false); const [reinstallingFusion, setReinstallingFusion] = useState(false); const [newSource, setNewSource] = useState(""); const [expandedPackages, setExpandedPackages] = useState>(new Set()); // Discovered extensions state const [extensions, setExtensions] = useState([]); const [extensionsLoading, setExtensionsLoading] = useState(true); const [updatingExtensions, setUpdatingExtensions] = useState(false); const loadSettings = useCallback(async () => { try { setLoading(true); const data = await fetchPiSettings(); setSettings(data); } catch (err) { addToast(t("piManager.loadSettingsFailed", "Failed to load Pi settings: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error"); } finally { setLoading(false); } }, [addToast, t]); const loadExtensions = useCallback(async () => { try { setExtensionsLoading(true); const data = await fetchPiExtensions(projectId); setExtensions(data.extensions); } catch (err) { addToast(t("piManager.loadExtensionsFailed", "Failed to load extensions: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error"); } finally { setExtensionsLoading(false); } }, [addToast, projectId, t]); const handleToggleExtension = useCallback(async (ext: PiExtensionEntry) => { try { setUpdatingExtensions(true); const disabledIds = ext.enabled ? [...extensions.filter((e) => e.enabled && e.id !== ext.id).map((e) => e.id), ext.id] : extensions.filter((e) => e.enabled && e.id !== ext.id).map((e) => e.id); await updatePiExtensions(disabledIds, projectId); await loadExtensions(); addToast(ext.enabled ? t("piManager.extensionDisabled", "Extension disabled") : t("piManager.extensionEnabled", "Extension enabled"), "success"); } catch (err) { addToast(t("piManager.updateFailed", "Failed to update extension: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error"); } finally { setUpdatingExtensions(false); } }, [extensions, projectId, loadExtensions, addToast, t]); useEffect(() => { void loadSettings(); }, [loadSettings]); useEffect(() => { void loadExtensions(); }, [loadExtensions]); const toggleExpanded = (index: number) => { setExpandedPackages((prev) => { const next = new Set(prev); if (next.has(index)) { next.delete(index); } else { next.add(index); } return next; }); }; const handleInstall = async () => { if (!newSource.trim()) { addToast(t("piManager.enterSource", "Please enter a package source"), "error"); return; } try { setInstalling(true); await installPiPackage(newSource.trim()); addToast(t("piManager.installSuccess", "Package installed successfully"), "success"); setNewSource(""); await loadSettings(); } catch (err) { addToast(t("piManager.installFailed", "Failed to install package: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error"); } finally { setInstalling(false); } }; const handleReinstallFusion = async () => { try { setReinstallingFusion(true); await reinstallFusionPiPackage(projectId); await Promise.all([loadSettings(), loadExtensions()]); addToast(t("piManager.reinstallSuccess", "Fusion skill reinstalled successfully"), "success"); } catch (err) { addToast(t("piManager.reinstallFailed", "Failed to reinstall Fusion skill: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error"); } finally { setReinstallingFusion(false); } }; const handleRemovePackage = async (sourceToRemove: string) => { if (!settings) return; const updatedPackages = settings.packages.filter((pkg) => { const pkgSource = typeof pkg === "string" ? pkg : pkg.source; return pkgSource !== sourceToRemove; }); try { await updatePiSettings({ packages: updatedPackages }); addToast(t("piManager.packageRemoved", "Package removed"), "success"); await loadSettings(); } catch (err) { addToast(t("piManager.removeFailed", "Failed to remove package: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error"); } }; const handleRemoveResource = async (type: "extensions" | "skills" | "prompts" | "themes", pathToRemove: string) => { if (!settings) return; const updated = settings[type].filter((p) => p !== pathToRemove); try { await updatePiSettings({ [type]: updated }); const resourceType = t(`piManager.resourceType.${type.slice(0, -1)}`, type.slice(0, -1)); addToast(t("piManager.resourceRemoved", "{{resource}} removed", { resource: resourceType }), "success"); await loadSettings(); } catch (err) { addToast(t("piManager.updateSettingsFailed", "Failed to update settings: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error"); } }; const renderResourceSection = ( label: string, Icon: typeof Puzzle, type: "extensions" | "skills" | "prompts" | "themes" ) => { if (!settings || settings[type].length === 0) return null; return (
{label} {settings[type].length}
{settings[type].map((path, index) => ( {path} ))}
); }; return (

{t("piManager.title", "Pi Extensions")}

{loading ? (
) : !settings ? (

{t("piManager.loadingFailed", "Failed to load Pi settings.")}

) : ( <> {/* Add package form */}
setNewSource(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleInstall(); } }} disabled={installing} />
{/* Package list */} {settings.packages.length > 0 ? (
{settings.packages.map((pkg, index) => { const source = typeof pkg === "string" ? pkg : pkg.source; const type = getPackageType(source); const label = getPackageLabel(source); const isObject = typeof pkg === "object" && pkg !== null; const hasFilters = isObject && ((pkg as { extensions?: string[] }).extensions?.length ?? 0) > 0 || ((pkg as { skills?: string[] }).skills?.length ?? 0) > 0 || ((pkg as { prompts?: string[] }).prompts?.length ?? 0) > 0 || ((pkg as { themes?: string[] }).themes?.length ?? 0) > 0; const isExpanded = expandedPackages.has(index); return (
{isObject && hasFilters ? ( ) : ( )} {type} {label}
{isObject && hasFilters && ( {t("piExtensions.filterHint", "{{extensions}} ext, {{skills}} skill, {{prompts}} prompt, {{themes}} theme", { extensions: (pkg as { extensions?: string[] }).extensions?.length ?? 0, skills: (pkg as { skills?: string[] }).skills?.length ?? 0, prompts: (pkg as { prompts?: string[] }).prompts?.length ?? 0, themes: (pkg as { themes?: string[] }).themes?.length ?? 0, })} )}
{isObject && hasFilters && isExpanded && (
{(pkg as { extensions?: string[] }).extensions?.length ? (
{t("piManager.filterExtensions", "Extensions:")} {(pkg as { extensions: string[] }).extensions!.map((ext, i) => ( {ext} ))}
) : null} {(pkg as { skills?: string[] }).skills?.length ? (
{t("piManager.filterSkills", "Skills:")} {(pkg as { skills: string[] }).skills!.map((skill, i) => ( {skill} ))}
) : null} {(pkg as { prompts?: string[] }).prompts?.length ? (
{t("piManager.filterPrompts", "Prompts:")} {(pkg as { prompts: string[] }).prompts!.map((prompt, i) => ( {prompt} ))}
) : null} {(pkg as { themes?: string[] }).themes?.length ? (
{t("piManager.filterThemes", "Themes:")} {(pkg as { themes: string[] }).themes!.map((theme, i) => ( {theme} ))}
) : null}
)}
); })}
) : (

{t("piManager.noPackages", "No packages configured.")}

{t("piManager.noPackagesHelp", "Add a package source above to get started.")}

)} {/* Top-level resource sections */}
{renderResourceSection(t("piManager.sectionExtensions", "Extensions"), Puzzle, "extensions")} {renderResourceSection(t("piManager.sectionSkills", "Skills"), BookOpen, "skills")} {renderResourceSection(t("piManager.sectionPrompts", "Prompts"), FileText, "prompts")} {renderResourceSection(t("piManager.sectionThemes", "Themes"), Palette, "themes")}
)} {/* Discovered Extensions Section */}

{t("piManager.discoveredTitle", "Discovered Extensions")}

{t("piManager.discoveredDescription", "Installed extensions resolved from packages and configured paths.")}

{extensionsLoading ? (
) : extensions.length === 0 ? (

{t("piManager.noExtensions", "No extensions discovered.")}

) : (
{extensions.map((ext) => (
{ext.name} {getSourceLabel(ext.source)}
{ext.path}
))}
)}
); }