/** * State manager for the Extension Control Center. * Handles data loading, tree building, filtering, and toggle persistence. */ import * as path from "node:path"; import { logger } from "@oh-my-pi/pi-utils"; import type { ContextFile } from "../../../capability/context-file"; import type { ExtensionModule } from "../../../capability/extension-module"; import type { Hook } from "../../../capability/hook"; import type { MCPServer } from "../../../capability/mcp"; import type { Prompt } from "../../../capability/prompt"; import type { Rule } from "../../../capability/rule"; import type { Skill } from "../../../capability/skill"; import type { SlashCommand } from "../../../capability/slash-command"; import type { CustomTool } from "../../../capability/tool"; import type { SourceMeta } from "../../../capability/types"; import { disableProvider, enableProvider, getAllProvidersInfo, isProviderEnabled, loadCapability, } from "../../../discovery"; import type { DashboardState, Extension, ExtensionKind, ExtensionState, FlatTreeItem, ProviderTab, TreeNode, } from "./types"; import { makeExtensionId, sourceFromMeta } from "./types"; /** * Settings manager interface for granular toggle persistence. */ export interface ExtensionSettingsManager { getDisabledExtensions(): string[]; setDisabledExtensions(ids: string[]): void; } /** * Load all extensions from all capabilities. */ export async function loadAllExtensions(cwd?: string, disabledIds?: string[]): Promise { const extensions: Extension[] = []; const disabledExtensions = new Set(disabledIds ?? []); // Helper to convert capability items to extensions function addItems( items: T[], kind: ExtensionKind, opts?: { getDescription?: (item: T) => string | undefined; getTrigger?: (item: T) => string | undefined; getShadowedBy?: (item: T) => string | undefined; }, ): void { for (const item of items) { const id = makeExtensionId(kind, item.name); const isDisabled = disabledExtensions.has(id); const isShadowed = (item as { _shadowed?: boolean })._shadowed; const providerEnabled = isProviderEnabled(item._source.provider); let state: ExtensionState; let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined; // Item-disabled takes precedence over shadowed if (isDisabled) { state = "disabled"; disabledReason = "item-disabled"; } else if (isShadowed) { state = "shadowed"; disabledReason = "shadowed"; } else if (!providerEnabled) { state = "disabled"; disabledReason = "provider-disabled"; } else { state = "active"; } extensions.push({ id, kind, name: item.name, displayName: item.name, description: opts?.getDescription?.(item), trigger: opts?.getTrigger?.(item), path: item.path, source: sourceFromMeta(item._source), state, disabledReason, shadowedBy: opts?.getShadowedBy?.(item), raw: item, }); } } const loadOpts = cwd ? { cwd, includeDisabled: true } : { includeDisabled: true }; // Load skills try { const skills = await loadCapability("skills", loadOpts); addItems(skills.all, "skill", { getDescription: s => s.frontmatter?.description, getTrigger: s => s.frontmatter?.globs?.join(", "), }); } catch (error) { logger.warn("Failed to load skills capability", { error: String(error) }); } // Load rules try { const rules = await loadCapability("rules", loadOpts); addItems(rules.all, "rule", { getDescription: r => r.description, getTrigger: r => r.globs?.join(", ") || (r.alwaysApply ? "always" : undefined), }); } catch (error) { logger.warn("Failed to load rules capability", { error: String(error) }); } // Load custom tools try { const tools = await loadCapability("tools", loadOpts); addItems(tools.all, "tool", { getDescription: t => t.description, }); } catch (error) { logger.warn("Failed to load tools capability", { error: String(error) }); } // Load extension modules try { const modules = await loadCapability("extension-modules", loadOpts); const nativeModules = modules.all.filter(module => module._source.provider === "native"); addItems(nativeModules, "extension-module"); } catch (error) { logger.warn("Failed to load extension-modules capability", { error: String(error) }); } // Load MCP servers try { const mcps = await loadCapability("mcps", loadOpts); for (const server of mcps.all) { const id = makeExtensionId("mcp", server.name); const isDisabled = disabledExtensions.has(id); const isShadowed = (server as { _shadowed?: boolean })._shadowed; const providerEnabled = isProviderEnabled(server._source.provider); let state: ExtensionState; let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined; if (isDisabled) { state = "disabled"; disabledReason = "item-disabled"; } else if (isShadowed) { state = "shadowed"; disabledReason = "shadowed"; } else if (!providerEnabled) { state = "disabled"; disabledReason = "provider-disabled"; } else { state = "active"; } extensions.push({ id, kind: "mcp", name: server.name, displayName: server.name, description: server.command || server.url, trigger: server.transport || "stdio", path: server._source.path, source: sourceFromMeta(server._source), state, disabledReason, raw: server, }); } } catch (error) { logger.warn("Failed to load mcps capability", { error: String(error) }); } // Load prompts try { const prompts = await loadCapability("prompts", loadOpts); addItems(prompts.all, "prompt", { getDescription: () => undefined, getTrigger: p => `/prompts:${p.name}`, }); } catch (error) { logger.warn("Failed to load prompts capability", { error: String(error) }); } // Load slash commands try { const commands = await loadCapability("slash-commands", loadOpts); addItems(commands.all, "slash-command", { getDescription: () => undefined, getTrigger: c => `/${c.name}`, }); } catch (error) { logger.warn("Failed to load slash-commands capability", { error: String(error) }); } // Load hooks try { const hooks = await loadCapability("hooks", loadOpts); for (const hook of hooks.all) { const id = makeExtensionId("hook", `${hook.type}:${hook.tool}:${hook.name}`); const isDisabled = disabledExtensions.has(id); const isShadowed = (hook as { _shadowed?: boolean })._shadowed; const providerEnabled = isProviderEnabled(hook._source.provider); let state: ExtensionState; let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined; if (isDisabled) { state = "disabled"; disabledReason = "item-disabled"; } else if (isShadowed) { state = "shadowed"; disabledReason = "shadowed"; } else if (!providerEnabled) { state = "disabled"; disabledReason = "provider-disabled"; } else { state = "active"; } extensions.push({ id, kind: "hook", name: hook.name, displayName: hook.name, description: `${hook.type}-${hook.tool}`, trigger: `${hook.type}:${hook.tool}`, path: hook.path, source: sourceFromMeta(hook._source), state, disabledReason, raw: hook, }); } } catch (error) { logger.warn("Failed to load hooks capability", { error: String(error) }); } // Load context files try { const contextFiles = await loadCapability("context-files", loadOpts); for (const file of contextFiles.all) { // Extract filename from path for display const name = path.basename(file.path); const id = makeExtensionId("context-file", `${file.level}:${name}`); const isDisabled = disabledExtensions.has(id); const isShadowed = (file as { _shadowed?: boolean })._shadowed; const providerEnabled = isProviderEnabled(file._source.provider); let state: ExtensionState; let disabledReason: "shadowed" | "provider-disabled" | "item-disabled" | undefined; if (isDisabled) { state = "disabled"; disabledReason = "item-disabled"; } else if (isShadowed) { state = "shadowed"; disabledReason = "shadowed"; } else if (!providerEnabled) { state = "disabled"; disabledReason = "provider-disabled"; } else { state = "active"; } extensions.push({ id, kind: "context-file", name, displayName: name, description: file.level === "user" ? "User-level context" : "Project-level context", trigger: file.level, path: file.path, source: sourceFromMeta(file._source), state, disabledReason, raw: file, }); } } catch (error) { logger.warn("Failed to load context-files capability", { error: String(error) }); } return extensions; } /** * Build sidebar tree from extensions. * Groups by provider → kind. */ export function buildSidebarTree(extensions: Extension[]): TreeNode[] { const providers = getAllProvidersInfo(); const tree: TreeNode[] = []; // Group extensions by provider and kind const byProvider = new Map>(); for (const ext of extensions) { const providerId = ext.source.provider; if (!byProvider.has(providerId)) { byProvider.set(providerId, new Map()); } const byKind = byProvider.get(providerId)!; if (!byKind.has(ext.kind)) { byKind.set(ext.kind, []); } byKind.get(ext.kind)!.push(ext); } // Build tree nodes for each provider (show ALL providers, even if disabled/empty) for (const provider of providers) { // Skip the 'native' provider as it cannot be toggled if (provider.id === "native") continue; const byKind = byProvider.get(provider.id); const kindNodes: TreeNode[] = []; let totalCount = 0; if (byKind && byKind.size > 0) { for (const [kind, exts] of byKind) { totalCount += exts.length; kindNodes.push({ id: `${provider.id}:${kind}`, label: getKindDisplayName(kind), type: "kind", enabled: provider.enabled, collapsed: true, children: [], count: exts.length, }); } // Sort kind nodes by count (most items first) kindNodes.sort((a, b) => (b.count || 0) - (a.count || 0)); } tree.push({ id: provider.id, label: provider.displayName, type: "provider", enabled: provider.enabled, collapsed: false, children: kindNodes, count: totalCount, }); } return tree; } /** * Flatten tree for keyboard navigation. */ export function flattenTree(tree: TreeNode[]): FlatTreeItem[] { const flat: FlatTreeItem[] = []; let index = 0; function walk(node: TreeNode, depth: number): void { flat.push({ node, depth, index: index++ }); if (!node.collapsed) { for (const child of node.children) { walk(child, depth + 1); } } } for (const node of tree) { walk(node, 0); } return flat; } /** * Apply fuzzy filter to extensions. */ export function applyFilter(extensions: Extension[], query: string): Extension[] { if (!query.trim()) { return extensions; } const tokens = query.toLowerCase().split(/\s+/).filter(Boolean); if (tokens.length === 0) { return extensions; } return extensions.filter(ext => { const searchable = [ ext.name, ext.displayName, ext.description || "", ext.trigger || "", ext.source.providerName, ext.kind, ] .join(" ") .toLowerCase(); return tokens.every(token => searchable.includes(token)); }); } /** * Get display name for extension kind. */ function getKindDisplayName(kind: ExtensionKind): string { switch (kind) { case "extension-module": return "Extension Modules"; case "skill": return "Skills"; case "rule": return "Rules"; case "tool": return "Tools"; case "mcp": return "MCP Servers"; case "prompt": return "Prompts"; case "instruction": return "Instructions"; case "context-file": return "Context Files"; case "hook": return "Hooks"; case "slash-command": return "Slash Commands"; default: return kind; } } /** * Build provider tabs from extensions. */ export function buildProviderTabs(extensions: Extension[]): ProviderTab[] { const providers = getAllProvidersInfo(); const tabs: ProviderTab[] = []; // Count extensions per provider const countByProvider = new Map(); for (const ext of extensions) { const count = countByProvider.get(ext.source.provider) ?? 0; countByProvider.set(ext.source.provider, count + 1); } // ALL tab first tabs.push({ id: "all", label: "ALL", enabled: true, count: extensions.length, }); // Provider tabs (skip native) for (const provider of providers) { if (provider.id === "native") continue; const count = countByProvider.get(provider.id) ?? 0; tabs.push({ id: provider.id, label: provider.displayName, enabled: provider.enabled, count, }); } // Sort: ALL first, then enabled by count, then disabled by count, then empty tabs.sort((a, b) => { if (a.id === "all") return -1; if (b.id === "all") return 1; // Categorize: 0 = enabled with content, 1 = disabled, 2 = empty+enabled const category = (t: ProviderTab) => { if (t.count === 0 && t.enabled) return 2; // empty if (!t.enabled) return 1; // disabled return 0; // enabled with content }; const aCat = category(a); const bCat = category(b); if (aCat !== bCat) return aCat - bCat; // Within same category, sort by count descending return b.count - a.count; }); return tabs; } /** * Filter extensions by provider tab. */ export function filterByProvider(extensions: Extension[], providerId: string): Extension[] { if (providerId === "all") { return extensions; } return extensions.filter(ext => ext.source.provider === providerId); } function isShadowedExtension(ext: Extension): boolean { if (ext.shadowedBy) return true; return Boolean((ext.raw as { _shadowed?: boolean } | null | undefined)?._shadowed); } /** * Apply setting-backed item disable overrides to an existing dashboard state. * This gives the UI immediate feedback while the full capability refresh runs. */ export function applyDisabledExtensionsToState(state: DashboardState, disabledIds: string[]): DashboardState { const disabled = new Set(disabledIds); const updateExtension = (ext: Extension): Extension => { if (disabled.has(ext.id)) { if (ext.state === "disabled" && ext.disabledReason === "item-disabled") return ext; return { ...ext, state: "disabled", disabledReason: "item-disabled" }; } if (ext.state !== "disabled" || ext.disabledReason !== "item-disabled") return ext; if (!isProviderEnabled(ext.source.provider)) { return { ...ext, state: "disabled", disabledReason: "provider-disabled" }; } if (isShadowedExtension(ext)) { const shadowed: Extension = { ...ext, state: "shadowed", disabledReason: "shadowed" }; return shadowed; } const enabled: Extension = { ...ext, state: "active" }; delete enabled.disabledReason; return enabled; }; return { ...state, extensions: state.extensions.map(updateExtension), tabFiltered: state.tabFiltered.map(updateExtension), searchFiltered: state.searchFiltered.map(updateExtension), selected: state.selected ? updateExtension(state.selected) : null, }; } /** * Create initial dashboard state. */ export async function createInitialState(cwd?: string, disabledIds?: string[]): Promise { const extensions = await loadAllExtensions(cwd, disabledIds); const tabs = buildProviderTabs(extensions); const tabFiltered = extensions; // "all" tab by default const searchFiltered = tabFiltered; return { tabs, activeTabIndex: 0, extensions, tabFiltered, searchFiltered, searchQuery: "", listIndex: 0, scrollOffset: 0, selected: searchFiltered[0] ?? null, }; } /** * Toggle provider enabled state. */ export function toggleProvider(providerId: string): boolean { if (isProviderEnabled(providerId)) { disableProvider(providerId); return false; } else { enableProvider(providerId); return true; } } /** * Refresh state after toggle. */ export async function refreshState( state: DashboardState, cwd?: string, disabledIds?: string[], ): Promise { const extensions = await loadAllExtensions(cwd, disabledIds); const tabs = buildProviderTabs(extensions); // Get current provider from tabs const activeTab = state.tabs[state.activeTabIndex]; const providerId = activeTab?.id ?? "all"; // Re-apply filters const tabFiltered = filterByProvider(extensions, providerId); const searchFiltered = applyFilter(tabFiltered, state.searchQuery); // Find new index for current provider (tabs may have reordered) const newActiveTabIndex = tabs.findIndex(t => t.id === providerId); const activeTabIndex = newActiveTabIndex >= 0 ? newActiveTabIndex : 0; // Try to preserve selection const selectedId = state.selected?.id; let selected = selectedId ? searchFiltered.find(e => e.id === selectedId) : null; if (!selected && searchFiltered.length > 0) { selected = searchFiltered[Math.min(state.listIndex, searchFiltered.length - 1)]; } return { ...state, tabs, activeTabIndex, extensions, tabFiltered, searchFiltered, selected: selected ?? null, listIndex: selected ? searchFiltered.indexOf(selected) : 0, }; }