/** * Interactive extension manager panel. * * Pattern: pi-skills-manager custom TUI (Input + manual render loop). * - Input for type-ahead search (plain = name, /prefix = path, @prefix = source) * - Grouped flat list with group headers * - View modes: by-source | a-z | active-first (Tab cycles) * - Space/Enter toggles local extension state * - a = package actions menu, u = update, x = remove, r = remote browse * - After close, prompts reload if local extensions changed */ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; import { DynamicBorder, rawKeyHint } from "@mariozechner/pi-coding-agent"; import { Container, getKeybindings, Input, matchesKey, Spacer, truncateToWidth, visibleWidth, } from "@mariozechner/pi-tui"; import type { ExtensionManagerController } from "../controller.js"; import { discoverExtensions, removeLocalExtension, setExtensionState } from "../extensions/discovery.js"; import { getInstalledPackages } from "../packages/discovery.js"; import { removePackageWithOutcome, updatePackagesWithOutcome as updateAllPackagesWithOutcome, updatePackageWithOutcome, } from "../packages/management.js"; import type { ExtensionEntry, InstalledPackage, State } from "../types/index.js"; import { logExtensionDelete, logExtensionToggle } from "../utils/history.js"; import { getPackageSourceKind, normalizePackageIdentity } from "../utils/package-source.js"; import { runTaskWithLoader } from "./async-task.js"; import { configurePackageExtensions } from "./package-config.js"; import { showRemote } from "./remote.js"; import { formatSize, getPackageIcon, getScopeIcon, getStatusIcon } from "./theme.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface LocalItem { kind: "local"; id: string; displayName: string; summary: string; scope: "global" | "project"; state: State; originalState: State; activePath: string; disabledPath: string; } interface PackageItem { kind: "package"; id: string; displayName: string; summary: string; scope: "global" | "project"; source: string; version: string | undefined; description: string | undefined; size: number | undefined; updateAvailable: boolean; pkg: InstalledPackage; } type Item = LocalItem | PackageItem; interface Group { key: string; label: string; scope: "global" | "project"; kind: "local" | "package"; items: Item[]; } type FlatEntry = { type: "group"; group: Group } | { type: "item"; item: Item }; type ViewMode = "by-source" | "a-z" | "active-first"; const VIEW_MODES: readonly ViewMode[] = ["by-source", "a-z", "active-first"]; const VIEW_LABELS: Record = { "by-source": "By source", "a-z": "A\u2013Z", "active-first": "Active first", }; // --------------------------------------------------------------------------- // Data loading // --------------------------------------------------------------------------- async function loadData(ctx: ExtensionCommandContext, pi: ExtensionAPI, controller: ExtensionManagerController) { return runTaskWithLoader( ctx, { title: "Extension Manager", message: "Loading extensions and packages..." }, async ({ signal, setMessage }) => { const [localEntries, installedPackages] = await Promise.all([ discoverExtensions(ctx.cwd), getInstalledPackages( ctx, pi, (cur, total) => { if (total > 0) setMessage(`Loading package metadata... ${cur}/${total}`); }, signal, ), ]); const knownUpdates = controller.getKnownUpdates(ctx); return buildGroups(localEntries, installedPackages, knownUpdates); }, ); } // --------------------------------------------------------------------------- // Group building // --------------------------------------------------------------------------- function normalizePath(p: string): string { const n = p.replace(/\\/g, "/"); return /^[a-zA-Z]:\//.test(n) ? n.toLowerCase() : n; } function buildGroups( localEntries: ExtensionEntry[], installedPackages: InstalledPackage[], knownUpdates: Set, ): Group[] { // Local extension groups by scope const localByScope = new Map(); const localPaths = new Set(); for (const e of localEntries) { localPaths.add(normalizePath(e.activePath)); const key = `local:${e.scope}`; if (!localByScope.has(key)) localByScope.set(key, []); const localGroup = localByScope.get(key); if (!localGroup) continue; localGroup.push({ kind: "local", id: e.id, displayName: e.displayName, summary: e.summary, scope: e.scope, state: e.state, originalState: e.state, activePath: e.activePath, disabledPath: e.disabledPath, }); } // Package groups — deduplicate against local paths const pkgByScope = new Map(); for (const pkg of installedPackages) { const srcNorm = normalizePath(pkg.source); const resNorm = pkg.resolvedPath ? normalizePath(pkg.resolvedPath) : ""; const isDup = [...localPaths].some( (lp) => srcNorm === lp || resNorm === lp || (resNorm && (lp.startsWith(`${resNorm}/`) || resNorm.startsWith(lp))) || (resNorm && resNorm === lp.split("/").slice(0, -1).join("/")), ); if (isDup) continue; const key = `package:${pkg.scope}`; if (!pkgByScope.has(key)) pkgByScope.set(key, []); const pkgGroup = pkgByScope.get(key); if (!pkgGroup) continue; pkgGroup.push({ kind: "package", id: `pkg:${pkg.source}`, displayName: pkg.name, summary: pkg.description ?? `${pkg.source} (${pkg.scope})`, scope: pkg.scope, source: pkg.source, version: pkg.version, description: pkg.description, size: pkg.size, updateAvailable: knownUpdates.has(normalizePackageIdentity(pkg.source)), pkg, }); } const groups: Group[] = []; for (const [key, items] of localByScope) { const scope = key.split(":")[1] as "global" | "project"; groups.push({ key, label: scope === "global" ? "Local extensions (global)" : "Local extensions (project)", scope, kind: "local", items: items.sort((a, b) => a.displayName.localeCompare(b.displayName)), }); } for (const [key, items] of pkgByScope) { const scope = key.split(":")[1] as "global" | "project"; groups.push({ key, label: scope === "global" ? "Installed packages (global)" : "Installed packages (project)", scope, kind: "package", items: items.sort((a, b) => a.displayName.localeCompare(b.displayName)), }); } // Sort: local before packages, global before project groups.sort((a, b) => { const kindRank = (k: string) => (k === "local" ? 0 : 1); if (a.kind !== b.kind) return kindRank(a.kind) - kindRank(b.kind); const scopeRank = (s: string) => (s === "global" ? 0 : 1); return scopeRank(a.scope) - scopeRank(b.scope); }); return groups; } // --------------------------------------------------------------------------- // Flat list builders per view mode // --------------------------------------------------------------------------- function buildFlatBySource(groups: Group[]): FlatEntry[] { const flat: FlatEntry[] = []; for (const group of groups) { if (group.items.length === 0) continue; flat.push({ type: "group", group }); for (const item of group.items) flat.push({ type: "item", item }); } return flat; } function buildFlatAZ(groups: Group[]): FlatEntry[] { const allItems: Item[] = groups.flatMap((g) => g.items); allItems.sort((a, b) => a.displayName.localeCompare(b.displayName)); return allItems.map((item) => ({ type: "item" as const, item })); } function buildFlatActiveFirst(groups: Group[]): FlatEntry[] { const allItems: Item[] = groups.flatMap((g) => g.items); const active = allItems.filter((i) => i.kind !== "local" || i.state === "enabled"); const inactive = allItems.filter((i) => i.kind === "local" && i.state === "disabled"); active.sort((a, b) => a.displayName.localeCompare(b.displayName)); inactive.sort((a, b) => a.displayName.localeCompare(b.displayName)); return [...active, ...inactive].map((item) => ({ type: "item" as const, item })); } function buildFlatList(groups: Group[], mode: ViewMode): FlatEntry[] { if (mode === "a-z") return buildFlatAZ(groups); if (mode === "active-first") return buildFlatActiveFirst(groups); return buildFlatBySource(groups); } function nextViewMode(current: ViewMode): ViewMode { const idx = VIEW_MODES.indexOf(current); return VIEW_MODES[(idx + 1) % VIEW_MODES.length] ?? "by-source"; } // --------------------------------------------------------------------------- // Search / filter // --------------------------------------------------------------------------- function buildMatchFn(query: string): ((item: Item) => boolean) | undefined { const trimmed = query.trim(); if (!trimmed) return undefined; if (trimmed.startsWith("@")) { const lq = trimmed.slice(1).toLowerCase(); return (item) => { if (item.kind === "package") return item.source.toLowerCase().includes(lq); return false; }; } if (trimmed.startsWith("/")) { const lq = trimmed.slice(1).toLowerCase(); return (item) => { if (item.kind === "local") return item.activePath.toLowerCase().includes(lq); if (item.kind === "package") return item.source.toLowerCase().includes(lq); return false; }; } const lq = trimmed.toLowerCase(); return (item) => item.displayName.toLowerCase().includes(lq); } // --------------------------------------------------------------------------- // Item rendering // --------------------------------------------------------------------------- function renderLocalItem( item: LocalItem, staged: Map, selected: boolean, theme: ReturnType, width: number, ): string { const currentState = staged.get(item.id) ?? item.state; const changed = staged.has(item.id) && currentState !== item.originalState; const cursor = selected ? "> " : " "; const status = getStatusIcon(theme, currentState === "enabled" ? "enabled" : "disabled"); const scope = getScopeIcon(theme, item.scope); const name = selected ? theme.bold(item.displayName) : item.displayName; const changeMark = changed ? ` ${theme.fg("warning", "*")}` : ""; const summary = theme.fg("dim", item.summary); return truncateToWidth(`${cursor} ${status} [${scope}] ${name}${changeMark} ${summary}`, width, "..."); } function renderPackageItem( item: PackageItem, selected: boolean, theme: ReturnType, width: number, ): string { const cursor = selected ? "> " : " "; const kind = getPackageSourceKind(item.source); const pkgIcon = getPackageIcon(theme, kind === "npm" || kind === "git" || kind === "local" ? kind : "local"); const scope = getScopeIcon(theme, item.scope); const name = selected ? theme.bold(item.displayName) : item.displayName; const version = item.version ? theme.fg("dim", `@${item.version}`) : ""; const updateBadge = item.updateAvailable ? ` ${theme.fg("warning", "[update]")}` : ""; const infoParts: string[] = []; if (item.description) infoParts.push(item.description.slice(0, 40)); else infoParts.push(kind === "npm" || kind === "git" ? kind : "local"); if (item.size !== undefined) infoParts.push(formatSize(theme, item.size)); const summary = theme.fg("dim", infoParts.join(" · ")); return truncateToWidth(`${cursor} ${pkgIcon} [${scope}] ${name}${version}${updateBadge} ${summary}`, width, "..."); } // --------------------------------------------------------------------------- // Non-interactive fallback // --------------------------------------------------------------------------- export async function showListOnly(ctx: ExtensionCommandContext): Promise { const entries = await discoverExtensions(ctx.cwd); if (entries.length === 0) { const msg = "No extensions found in ~/.pi/agent/extensions or .pi/extensions"; if (ctx.hasUI) ctx.ui.notify(msg, "info"); else console.log(msg); return; } const lines = entries.map((e) => { const status = e.state === "enabled" ? "[x]" : "[ ]"; return ` ${status} [${e.scope[0]}] ${e.displayName} ${e.summary}`; }); const out = `Local extensions:\n${lines.join("\n")}`; if (ctx.hasUI) ctx.ui.notify(out, "info"); else console.log(out); } // --------------------------------------------------------------------------- // Panel action type // --------------------------------------------------------------------------- type PanelAction = | { action: "package-actions"; item: PackageItem } | { action: "update"; item: PackageItem } | { action: "details"; item: PackageItem } | { action: "configure"; item: PackageItem } | { action: "remove"; item: Item } | { action: "remote"; item: undefined } | { action: "install"; item: undefined } | { action: "update-all"; item: undefined } | { action: "auto-update"; item: undefined } | { action: "help"; item: undefined }; interface InteractiveSessionFlags { reloadRequired: boolean; restartRequired: boolean; } interface PanelActionResult { reloadRequired: boolean; restartRequired: boolean; reloadedNow: boolean; } const NO_PANEL_ACTION_RESULT: PanelActionResult = { reloadRequired: false, restartRequired: false, reloadedNow: false, }; // --------------------------------------------------------------------------- // Main interactive panel // --------------------------------------------------------------------------- export async function showInteractive( ctx: ExtensionCommandContext, pi: ExtensionAPI, controller: ExtensionManagerController, sessionFlags: InteractiveSessionFlags = { reloadRequired: false, restartRequired: false }, ): Promise { if (!ctx.hasUI) { await showListOnly(ctx); return; } const groupsOrNull = await loadData(ctx, pi, controller); if (!groupsOrNull) { await showListOnly(ctx); return; } const groups: Group[] = groupsOrNull; const allItems: Item[] = groups.flatMap((g) => g.items); if (allItems.length === 0) { const choice = await ctx.ui.select("No extensions or packages found", ["Browse community packages", "Cancel"]); if (choice === "Browse community packages") await showRemote("", ctx, pi); return; } const staged = new Map(); let changeCount = 0; const panelResult = await ctx.ui.custom((tui, theme, _kb, done) => { const kb = getKeybindings(); let viewMode: ViewMode = "by-source"; let masterList: FlatEntry[] = buildFlatList(groups, viewMode); let filteredItems: FlatEntry[] = [...masterList]; let selectedIndex = filteredItems.findIndex((e) => e.type === "item"); if (selectedIndex < 0) selectedIndex = 0; const searchInput = new Input(); let searchActive = false; function findNextItem(from: number, dir: number): number { const total = filteredItems.length; if (total === 0) return from; let idx = from; for (let step = 0; step < total; step++) { idx = (idx + dir + total) % total; if (filteredItems[idx]?.type === "item") return idx; } return from; } function selectFirstItem(): void { const idx = filteredItems.findIndex((e) => e.type === "item"); selectedIndex = idx >= 0 ? idx : 0; } function applyFilter(query: string): void { const matchFn = buildMatchFn(query); if (!matchFn) { filteredItems = [...masterList]; selectFirstItem(); return; } const matchingItems = new Set(); const matchingGroups = new Set(); for (const entry of masterList) { if (entry.type === "item" && matchFn(entry.item)) matchingItems.add(entry.item); } for (const group of groups) { for (const item of group.items) { if (matchingItems.has(item)) matchingGroups.add(group); } } filteredItems = masterList.filter( (e) => (e.type === "group" && matchingGroups.has(e.group)) || (e.type === "item" && matchingItems.has(e.item)), ); selectFirstItem(); } function rebuildForMode(): void { masterList = buildFlatList(groups, viewMode); applyFilter(searchInput.getValue()); } // --- Header --- const header = { invalidate() {}, render(width: number): string[] { const title = theme.bold("Extension Manager"); const sep = theme.fg("muted", " \u00b7 "); const hint = searchActive ? rawKeyHint("esc", "clear search") : rawKeyHint("space", "toggle") + sep + rawKeyHint("enter", "actions") + sep + rawKeyHint("/", "search") + sep + rawKeyHint("esc", "close"); const hintWidth = visibleWidth(hint); const titleWidth = visibleWidth(title); const spacing = Math.max(1, width - titleWidth - hintWidth); const headerLine = truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""); const dot = "\u00b7"; const shortcutVariants = searchActive ? [ `Filter: name ${dot} /path ${dot} @source ${dot} space toggle ${dot} enter actions ${dot} esc clear`, `Filter: name ${dot} /path ${dot} @source ${dot} spc toggle ${dot} enter act ${dot} esc clear`, `name ${dot} /path ${dot} @source ${dot} spc ${dot} enter ${dot} esc`, ] : [ `space toggle ${dot} a actions ${dot} i install ${dot} r remote ${dot} u update ${dot} U all ${dot} x remove ${dot} ? help`, `spc toggle ${dot} a act ${dot} i inst ${dot} r remote ${dot} u upd ${dot} U all ${dot} x rm ${dot} ? help`, `spc ${dot} a ${dot} i ${dot} r ${dot} u ${dot} U ${dot} x ${dot} ?`, ]; const shortcutText = shortcutVariants.find((v) => v.length <= width) ?? shortcutVariants.at(-1) ?? ""; const shortcutLine = truncateToWidth(theme.fg("muted", shortcutText), width, ""); return [headerLine, shortcutLine]; }, }; const maxVisible = 20; // --- List --- const list = { invalidate() {}, render(width: number): string[] { const lines: string[] = []; if (searchActive) { lines.push(...searchInput.render(width)); } lines.push(""); if (filteredItems.length === 0) { lines.push(theme.fg("muted", " No results")); return lines; } const startIndex = Math.max( 0, Math.min(selectedIndex - Math.floor(maxVisible / 2), filteredItems.length - maxVisible), ); const endIndex = Math.min(startIndex + maxVisible, filteredItems.length); for (let i = startIndex; i < endIndex; i++) { const entry = filteredItems[i]; if (!entry) continue; const isSelected = i === selectedIndex; if (entry.type === "group") { lines.push(truncateToWidth(` ${theme.fg("accent", theme.bold(entry.group.label))}`, width, "")); } else if (entry.item.kind === "local") { lines.push(renderLocalItem(entry.item, staged, isSelected, theme, width)); } else { lines.push(renderPackageItem(entry.item, isSelected, theme, width)); } } // Footer counter + view mode const itemCount = filteredItems.filter((e) => e.type === "item").length; const itemIndex = filteredItems.slice(0, selectedIndex + 1).filter((e) => e.type === "item").length; const modeLabel = VIEW_LABELS[viewMode]; const hasScroll = startIndex > 0 || endIndex < filteredItems.length; lines.push(theme.fg("dim", ` ${hasScroll ? `${itemIndex}/${itemCount} ` : ""}${modeLabel}`)); return lines; }, }; // --- Container --- const container = new Container(); container.addChild(new Spacer(1)); container.addChild(new DynamicBorder()); container.addChild(new Spacer(1)); container.addChild(header); container.addChild(new Spacer(1)); container.addChild(list); container.addChild(new Spacer(1)); container.addChild(new DynamicBorder()); return { render: (width: number) => container.render(width), invalidate: () => container.invalidate(), handleInput(data: string) { // --- Helpers (avoid duplication) --- function cycleView(): void { viewMode = nextViewMode(viewMode); rebuildForMode(); tui.requestRender(); } function getSelectedItem(): Item | undefined { const entry = filteredItems[selectedIndex]; return entry?.type === "item" ? entry.item : undefined; } function toggleLocal(item: LocalItem): void { const current = staged.get(item.id) ?? item.state; const next: State = current === "enabled" ? "disabled" : "enabled"; staged.set(item.id, next); item.state = next; for (const group of groups) { const found = group.items.find((i) => i.id === item.id); if (found?.kind === "local") found.state = next; } changeCount++; if (viewMode === "active-first") rebuildForMode(); } function isSpaceKey(d: string): boolean { return d === " "; } function isEnterKey(d: string): boolean { return d === "\r" || d === "\n" || kb.matches(d, "tui.select.confirm"); } function handleToggle(): void { const item = getSelectedItem(); if (item?.kind === "local") { toggleLocal(item); tui.requestRender(); } else if (item?.kind === "package") { done({ action: "configure", item }); } } function handleEnter(): void { const item = getSelectedItem(); if (item?.kind === "package") { done({ action: "package-actions", item }); } else if (item?.kind === "local") { toggleLocal(item); tui.requestRender(); } } // 1. Navigation (always active) if (kb.matches(data, "tui.select.up")) { selectedIndex = findNextItem(selectedIndex, -1); tui.requestRender(); return; } if (kb.matches(data, "tui.select.down")) { selectedIndex = findNextItem(selectedIndex, 1); tui.requestRender(); return; } if (kb.matches(data, "tui.select.pageUp")) { let t = Math.max(0, selectedIndex - maxVisible); while (t < filteredItems.length && filteredItems[t]?.type !== "item") t++; if (t < filteredItems.length) selectedIndex = t; tui.requestRender(); return; } if (kb.matches(data, "tui.select.pageDown")) { let t = Math.min(filteredItems.length - 1, selectedIndex + maxVisible); while (t >= 0 && filteredItems[t]?.type !== "item") t--; if (t >= 0) selectedIndex = t; tui.requestRender(); return; } // 2. Tab cycles view (both modes) if (matchesKey(data, "tab")) { cycleView(); return; } // 3. Space = toggle, Enter = actions/confirm (both modes) if (isSpaceKey(data)) { handleToggle(); return; } if (isEnterKey(data)) { handleEnter(); return; } // 4. Cancel / Escape if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "ctrl+c")) { if (searchActive) { searchActive = false; searchInput.handleInput("\x15"); // ctrl+u clears input applyFilter(""); tui.requestRender(); return; } done(undefined); return; } // 5. Search mode: remaining keys go to search input if (searchActive) { searchInput.handleInput(data); applyFilter(searchInput.getValue()); tui.requestRender(); return; } // --- Below: normal mode (search NOT active) --- // 6. Activate search if (data === "/" || data === "f" || data === "F") { searchActive = true; tui.requestRender(); return; } // 7. Shortcuts — actions on selected item const selectedItem = getSelectedItem(); if ((data === "a" || data === "A") && selectedItem?.kind === "package") { done({ action: "package-actions", item: selectedItem }); return; } if (data === "u" && selectedItem?.kind === "package") { done({ action: "update", item: selectedItem }); return; } if ((data === "v" || data === "V") && selectedItem?.kind === "package") { done({ action: "details", item: selectedItem }); return; } if ((data === "c" || data === "C") && selectedItem?.kind === "package") { done({ action: "configure", item: selectedItem }); return; } if ((data === "x" || data === "X") && selectedItem) { done({ action: "remove", item: selectedItem }); return; } // 8. Global shortcuts if (data === "i" || data === "I") { done({ action: "install", item: undefined }); return; } if (data === "U") { done({ action: "update-all", item: undefined }); return; } if (data === "t" || data === "T") { done({ action: "auto-update", item: undefined }); return; } if (data === "r" || data === "R") { done({ action: "remote", item: undefined }); return; } if (data === "?" || data === "h" || data === "H") { done({ action: "help", item: undefined }); return; } }, }; }); // Handle action signaled by the panel if (panelResult) { // First apply any pending staged changes if (staged.size > 0) { const applied = await applyStaged(staged, allItems, pi); if (applied > 0) { sessionFlags.reloadRequired = true; } } const handled = await handlePanelAction(panelResult, ctx, pi, controller); sessionFlags.reloadRequired ||= handled.reloadRequired; sessionFlags.restartRequired ||= handled.restartRequired; if (handled.reloadedNow) return; // Return to interactive list after any sub-action completes return showInteractive(ctx, pi, controller, sessionFlags); } // Panel closed normally (ESC) — apply staged changes if (changeCount > 0 || staged.size > 0) { const applied = await applyStaged(staged, allItems, pi); if (applied > 0) { sessionFlags.reloadRequired = true; ctx.ui.notify(`Saved ${applied} extension change(s).`, "info"); } } if (sessionFlags.restartRequired) { const restartNow = await ctx.ui.confirm( "Restart Required", "Package extension configuration changed. Finish now and restart pi?", ); if (restartNow) { ctx.ui.notify("Shutting down pi. Start it again to apply changes.", "info"); ctx.shutdown(); return; } ctx.ui.notify("Restart pi manually to apply package extension configuration changes.", "warning"); return; } if (sessionFlags.reloadRequired) { const reload = await ctx.ui.confirm("Reload Required", "Configuration changed. Reload pi now?"); if (reload) { await ctx.reload(); return; } ctx.ui.notify("Changes saved. Use /reload to apply.", "info"); } } // --------------------------------------------------------------------------- // Action handlers (called after panel closes with an action) // --------------------------------------------------------------------------- async function handlePanelAction( action: PanelAction, ctx: ExtensionCommandContext, pi: ExtensionAPI, controller: ExtensionManagerController, ): Promise { if (action.action === "remote") { const outcome = await showRemote("", ctx, pi, { reloadMode: "defer" }); return { ...NO_PANEL_ACTION_RESULT, reloadRequired: outcome.reloadRequired }; } if (action.action === "update") { const outcome = await updatePackageWithOutcome(action.item.source, ctx, pi, "defer"); return { ...NO_PANEL_ACTION_RESULT, reloadedNow: outcome.reloaded, reloadRequired: outcome.reloadRequired }; } if (action.action === "remove" && action.item.kind === "package") { const outcome = await removePackageWithOutcome(action.item.source, ctx, pi, "defer"); return { ...NO_PANEL_ACTION_RESULT, reloadedNow: outcome.reloaded, reloadRequired: outcome.reloadRequired }; } if (action.action === "remove" && action.item.kind === "local") { const item = action.item; const confirmed = await ctx.ui.confirm( "Delete Extension", `Delete ${item.displayName} from disk? This cannot be undone.`, ); if (!confirmed) return NO_PANEL_ACTION_RESULT; const removal = await removeLocalExtension( { activePath: item.activePath, disabledPath: item.disabledPath }, ctx.cwd, ); if (!removal.ok) { logExtensionDelete(pi, item.id, false, removal.error); ctx.ui.notify(`Failed to remove extension: ${removal.error}`, "error"); return NO_PANEL_ACTION_RESULT; } logExtensionDelete(pi, item.id, true); ctx.ui.notify(`Removed ${item.displayName}.`, "info"); return { ...NO_PANEL_ACTION_RESULT, reloadRequired: true }; } if (action.action === "details") { showPackageDetails(action.item, ctx); return NO_PANEL_ACTION_RESULT; } if (action.action === "configure") { const outcome = await configurePackageExtensions(action.item.pkg, ctx, pi, { restartMode: "defer" }); return { ...NO_PANEL_ACTION_RESULT, reloadedNow: outcome.reloaded, restartRequired: outcome.restartRequired, }; } if (action.action === "install") { const outcome = await showRemote("install", ctx, pi, { reloadMode: "defer" }); return { ...NO_PANEL_ACTION_RESULT, reloadRequired: outcome.reloadRequired }; } if (action.action === "update-all") { const outcome = await updateAllPackagesWithOutcome(ctx, pi, "defer"); return { ...NO_PANEL_ACTION_RESULT, reloadedNow: outcome.reloaded, reloadRequired: outcome.reloadRequired }; } if (action.action === "auto-update") { await controller.promptAutoUpdateWizard(ctx); return NO_PANEL_ACTION_RESULT; } if (action.action === "help") { showHelpNotification(ctx); return NO_PANEL_ACTION_RESULT; } if (action.action === "package-actions") { const item = action.item; const choice = await ctx.ui.select(item.displayName, [ "Configure extensions", "Update", "Remove", "Details", "Cancel", ]); if (!choice || choice === "Cancel") return NO_PANEL_ACTION_RESULT; if (choice === "Configure extensions") { const outcome = await configurePackageExtensions(item.pkg, ctx, pi, { restartMode: "defer" }); return { ...NO_PANEL_ACTION_RESULT, reloadedNow: outcome.reloaded, restartRequired: outcome.restartRequired, }; } else if (choice === "Update") { const outcome = await updatePackageWithOutcome(item.source, ctx, pi, "defer"); return { ...NO_PANEL_ACTION_RESULT, reloadedNow: outcome.reloaded, reloadRequired: outcome.reloadRequired }; } else if (choice === "Remove") { const outcome = await removePackageWithOutcome(item.source, ctx, pi, "defer"); return { ...NO_PANEL_ACTION_RESULT, reloadedNow: outcome.reloaded, reloadRequired: outcome.reloadRequired }; } else if (choice === "Details") { showPackageDetails(item, ctx); } return NO_PANEL_ACTION_RESULT; } return NO_PANEL_ACTION_RESULT; } async function applyStaged(staged: Map, allItems: Item[], pi: ExtensionAPI): Promise { let changed = 0; for (const [id, targetState] of staged) { const item = allItems.find((i) => i.id === id); if (!item || item.kind !== "local") continue; if (targetState === item.originalState) continue; const result = await setExtensionState( { activePath: item.activePath, disabledPath: item.disabledPath }, targetState, ); if (result.ok) { logExtensionToggle(pi, id, item.originalState, targetState, true); changed += 1; } else { logExtensionToggle(pi, id, item.originalState, targetState, false, result.error); } } return changed; } // --------------------------------------------------------------------------- // Package details // --------------------------------------------------------------------------- function showPackageDetails(item: PackageItem, ctx: ExtensionCommandContext): void { const parts = [ `Name: ${item.displayName}`, `Version: ${item.version ?? "unknown"}`, `Source: ${item.source}`, `Scope: ${item.scope}`, ]; if (item.size !== undefined) parts.push(`Size: ${formatSize(ctx.ui.theme, item.size)}`); if (item.description) parts.push(`Description: ${item.description}`); ctx.ui.notify(parts.join("\n"), "info"); } // --------------------------------------------------------------------------- // Help notification // --------------------------------------------------------------------------- function showHelpNotification(ctx: ExtensionCommandContext): void { const lines = [ "Extension Manager — Keyboard Shortcuts", "", "Navigation:", " Up/Down Navigate list", " PgUp/PgDn Jump pages", " Tab Cycle view mode (by-source / A-Z / active-first)", " Space/Enter Toggle extension / open package actions", "", "Search:", " / or f Activate search filter", " Esc Clear search (or close panel if search empty)", "", "Item shortcuts:", " a Actions menu (packages)", " u Update selected package", " v Details of selected package", " c Configure package extensions", " x Remove selected item", "", "Global shortcuts:", " i Install a package", " U Update all packages", " t Auto-update wizard", " r Browse remote packages", " ?/h Show this help", ]; ctx.ui.notify(lines.join("\n"), "info"); } // --------------------------------------------------------------------------- // Legacy re-exports for registry compatibility // --------------------------------------------------------------------------- /** @deprecated Use showInteractive with controller */ export async function showInstalledPackagesLegacy(ctx: ExtensionCommandContext, _pi: ExtensionAPI): Promise { ctx.ui.notify("Use /extensions to open the unified extension manager.", "info"); }