diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index f65fd2ab..fd108206 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -184,6 +184,7 @@ ${chalk.bold("Commands:")} ${APP_NAME} remove [-l] Remove extension source from settings ${APP_NAME} update [source] Update installed extensions (skips pinned sources) ${APP_NAME} list List installed extensions from settings + ${APP_NAME} config Open TUI to enable/disable package resources ${chalk.bold("Options:")} --provider Provider name (default: google) diff --git a/packages/coding-agent/src/cli/config-selector.ts b/packages/coding-agent/src/cli/config-selector.ts new file mode 100644 index 00000000..a4a140d7 --- /dev/null +++ b/packages/coding-agent/src/cli/config-selector.ts @@ -0,0 +1,353 @@ +/** + * TUI config selector for `pi config` command + */ + +import { existsSync, readdirSync, statSync } from "node:fs"; +import { basename, join } from "node:path"; +import { ProcessTerminal, TUI } from "@mariozechner/pi-tui"; +import { CONFIG_DIR_NAME } from "../config.js"; +import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../core/package-manager.js"; +import type { SettingsManager } from "../core/settings-manager.js"; +import { ConfigSelectorComponent } from "../modes/interactive/components/config-selector.js"; +import { initTheme, stopThemeWatcher } from "../modes/interactive/theme/theme.js"; + +export interface ConfigSelectorOptions { + resolvedPaths: ResolvedPaths; + settingsManager: SettingsManager; + cwd: string; + agentDir: string; +} + +type ResourceType = "extensions" | "skills" | "prompts" | "themes"; + +const FILE_PATTERNS: Record = { + extensions: /\.(ts|js)$/, + skills: /\.md$/, + prompts: /\.md$/, + themes: /\.json$/, +}; + +function collectFiles(dir: string, pattern: RegExp): string[] { + const files: string[] = []; + if (!existsSync(dir)) return files; + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith(".")) continue; + if (entry.name === "node_modules") continue; + + const fullPath = join(dir, entry.name); + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + + if (entry.isSymbolicLink()) { + try { + const stats = statSync(fullPath); + isDir = stats.isDirectory(); + isFile = stats.isFile(); + } catch { + continue; + } + } + + if (isDir) { + files.push(...collectFiles(fullPath, pattern)); + } else if (isFile && pattern.test(entry.name)) { + files.push(fullPath); + } + } + } catch { + // Ignore errors + } + + return files; +} + +/** + * Collect skill entries from a directory. + * Matches the behavior of loadSkillsFromDirInternal in skills.ts: + * - Direct .md files in the root directory + * - Subdirectories containing SKILL.md (returns the directory path) + * - Recursively checks subdirectories that don't have SKILL.md + */ +function collectSkillEntries(dir: string, isRoot = true): string[] { + const entries: string[] = []; + if (!existsSync(dir)) return entries; + + try { + const dirEntries = readdirSync(dir, { withFileTypes: true }); + for (const entry of dirEntries) { + if (entry.name.startsWith(".")) continue; + if (entry.name === "node_modules") continue; + + const fullPath = join(dir, entry.name); + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + + if (entry.isSymbolicLink()) { + try { + const stats = statSync(fullPath); + isDir = stats.isDirectory(); + isFile = stats.isFile(); + } catch { + continue; + } + } + + if (isDir) { + // Check for SKILL.md in subdirectory + const skillMd = join(fullPath, "SKILL.md"); + if (existsSync(skillMd)) { + // This is a skill directory, add it + entries.push(fullPath); + } else { + // Recurse into subdirectory to find skills + entries.push(...collectSkillEntries(fullPath, false)); + } + } else if (isFile && entry.name.endsWith(".md")) { + // Only include direct .md files at root level, or SKILL.md anywhere + if (isRoot || entry.name === "SKILL.md") { + entries.push(fullPath); + } + } + } + } catch { + // Ignore errors + } + + return entries; +} + +function collectExtensionEntries(dir: string): string[] { + const entries: string[] = []; + if (!existsSync(dir)) return entries; + + try { + const dirEntries = readdirSync(dir, { withFileTypes: true }); + for (const entry of dirEntries) { + if (entry.name.startsWith(".")) continue; + if (entry.name === "node_modules") continue; + + const fullPath = join(dir, entry.name); + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + + if (entry.isSymbolicLink()) { + try { + const stats = statSync(fullPath); + isDir = stats.isDirectory(); + isFile = stats.isFile(); + } catch { + continue; + } + } + + if (isFile && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) { + entries.push(fullPath); + } else if (isDir) { + // Check for index.ts/js or package.json with pi field + const indexTs = join(fullPath, "index.ts"); + const indexJs = join(fullPath, "index.js"); + if (existsSync(indexTs)) { + entries.push(indexTs); + } else if (existsSync(indexJs)) { + entries.push(indexJs); + } + // Skip subdirectories that don't have an entry point + } + } + } catch { + // Ignore errors + } + + return entries; +} + +function isExcludedByPatterns(filePath: string, patterns: string[]): boolean { + const name = basename(filePath); + for (const pattern of patterns) { + if (pattern.startsWith("!")) { + const excludePattern = pattern.slice(1); + // Match against basename or full path + if (name === excludePattern || filePath.endsWith(excludePattern)) { + return true; + } + } + } + return false; +} + +/** + * Merge auto-discovered resources into resolved paths. + * Auto-discovered resources are enabled by default unless explicitly disabled via settings. + */ +function mergeAutoDiscoveredResources( + resolvedPaths: ResolvedPaths, + settingsManager: SettingsManager, + cwd: string, + agentDir: string, +): ResolvedPaths { + const result: ResolvedPaths = { + extensions: [...resolvedPaths.extensions], + skills: [...resolvedPaths.skills], + prompts: [...resolvedPaths.prompts], + themes: [...resolvedPaths.themes], + }; + + const existingPaths = { + extensions: new Set(resolvedPaths.extensions.map((r) => r.path)), + skills: new Set(resolvedPaths.skills.map((r) => r.path)), + prompts: new Set(resolvedPaths.prompts.map((r) => r.path)), + themes: new Set(resolvedPaths.themes.map((r) => r.path)), + }; + + // Get exclusion patterns from settings + const globalSettings = settingsManager.getGlobalSettings(); + const projectSettings = settingsManager.getProjectSettings(); + + const userExclusions = { + extensions: globalSettings.extensions ?? [], + skills: globalSettings.skills ?? [], + prompts: globalSettings.prompts ?? [], + themes: globalSettings.themes ?? [], + }; + + const projectExclusions = { + extensions: projectSettings.extensions ?? [], + skills: projectSettings.skills ?? [], + prompts: projectSettings.prompts ?? [], + themes: projectSettings.themes ?? [], + }; + + const addResources = ( + target: ResolvedResource[], + existing: Set, + paths: string[], + metadata: PathMetadata, + exclusions: string[], + ) => { + for (const path of paths) { + if (!existing.has(path)) { + const enabled = !isExcludedByPatterns(path, exclusions); + target.push({ path, enabled, metadata }); + existing.add(path); + } + } + }; + + // User scope auto-discovery + const userExtDir = join(agentDir, "extensions"); + const userSkillsDir = join(agentDir, "skills"); + const userPromptsDir = join(agentDir, "prompts"); + const userThemesDir = join(agentDir, "themes"); + + addResources( + result.extensions, + existingPaths.extensions, + collectExtensionEntries(userExtDir), + { source: "auto", scope: "user", origin: "top-level" }, + userExclusions.extensions, + ); + addResources( + result.skills, + existingPaths.skills, + collectSkillEntries(userSkillsDir), + { source: "auto", scope: "user", origin: "top-level" }, + userExclusions.skills, + ); + addResources( + result.prompts, + existingPaths.prompts, + collectFiles(userPromptsDir, FILE_PATTERNS.prompts), + { source: "auto", scope: "user", origin: "top-level" }, + userExclusions.prompts, + ); + addResources( + result.themes, + existingPaths.themes, + collectFiles(userThemesDir, FILE_PATTERNS.themes), + { source: "auto", scope: "user", origin: "top-level" }, + userExclusions.themes, + ); + + // Project scope auto-discovery + const projectExtDir = join(cwd, CONFIG_DIR_NAME, "extensions"); + const projectSkillsDir = join(cwd, CONFIG_DIR_NAME, "skills"); + const projectPromptsDir = join(cwd, CONFIG_DIR_NAME, "prompts"); + const projectThemesDir = join(cwd, CONFIG_DIR_NAME, "themes"); + + addResources( + result.extensions, + existingPaths.extensions, + collectExtensionEntries(projectExtDir), + { source: "auto", scope: "project", origin: "top-level" }, + projectExclusions.extensions, + ); + addResources( + result.skills, + existingPaths.skills, + collectSkillEntries(projectSkillsDir), + { source: "auto", scope: "project", origin: "top-level" }, + projectExclusions.skills, + ); + addResources( + result.prompts, + existingPaths.prompts, + collectFiles(projectPromptsDir, FILE_PATTERNS.prompts), + { source: "auto", scope: "project", origin: "top-level" }, + projectExclusions.prompts, + ); + addResources( + result.themes, + existingPaths.themes, + collectFiles(projectThemesDir, FILE_PATTERNS.themes), + { source: "auto", scope: "project", origin: "top-level" }, + projectExclusions.themes, + ); + + return result; +} + +/** Show TUI config selector and return when closed */ +export async function selectConfig(options: ConfigSelectorOptions): Promise { + // Initialize theme before showing TUI + initTheme(options.settingsManager.getTheme(), true); + + // Merge auto-discovered resources with package manager results + const allPaths = mergeAutoDiscoveredResources( + options.resolvedPaths, + options.settingsManager, + options.cwd, + options.agentDir, + ); + + return new Promise((resolve) => { + const ui = new TUI(new ProcessTerminal()); + let resolved = false; + + const selector = new ConfigSelectorComponent( + allPaths, + options.settingsManager, + options.cwd, + () => { + if (!resolved) { + resolved = true; + ui.stop(); + stopThemeWatcher(); + resolve(); + } + }, + () => { + ui.stop(); + stopThemeWatcher(); + process.exit(0); + }, + () => ui.requestRender(), + ); + + ui.addChild(selector); + ui.setFocus(selector.getResourceList()); + ui.start(); + }); +} diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index dfc75826..177c3ecd 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -487,6 +487,13 @@ export class SettingsManager { this.save(); } + setProjectSkillPaths(paths: string[]): void { + const projectSettings = this.loadProjectSettings(); + projectSettings.skills = paths; + this.saveProjectSettings(projectSettings); + this.settings = deepMergeSettings(this.globalSettings, projectSettings); + } + getPromptTemplatePaths(): string[] { return [...(this.settings.prompts ?? [])]; } @@ -496,6 +503,13 @@ export class SettingsManager { this.save(); } + setProjectPromptTemplatePaths(paths: string[]): void { + const projectSettings = this.loadProjectSettings(); + projectSettings.prompts = paths; + this.saveProjectSettings(projectSettings); + this.settings = deepMergeSettings(this.globalSettings, projectSettings); + } + getThemePaths(): string[] { return [...(this.settings.themes ?? [])]; } @@ -505,6 +519,13 @@ export class SettingsManager { this.save(); } + setProjectThemePaths(paths: string[]): void { + const projectSettings = this.loadProjectSettings(); + projectSettings.themes = paths; + this.saveProjectSettings(projectSettings); + this.settings = deepMergeSettings(this.globalSettings, projectSettings); + } + getEnableSkillCommands(): boolean { return this.settings.enableSkillCommands ?? true; } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 12f40c77..ed8798ac 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -9,6 +9,7 @@ import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/ import chalk from "chalk"; import { createInterface } from "readline"; import { type Args, parseArgs, printHelp } from "./cli/args.js"; +import { selectConfig } from "./cli/config-selector.js"; import { processFileArguments } from "./cli/file-processor.js"; import { listModels } from "./cli/list-models.js"; import { selectSession } from "./cli/session-picker.js"; @@ -424,11 +425,37 @@ function buildSessionOptions( return options; } +async function handleConfigCommand(args: string[]): Promise { + if (args[0] !== "config") { + return false; + } + + const cwd = process.cwd(); + const agentDir = getAgentDir(); + const settingsManager = SettingsManager.create(cwd, agentDir); + const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); + + const resolvedPaths = await packageManager.resolve(); + + await selectConfig({ + resolvedPaths, + settingsManager, + cwd, + agentDir, + }); + + process.exit(0); +} + export async function main(args: string[]) { if (await handlePackageCommand(args)) { return; } + if (await handleConfigCommand(args)) { + return; + } + // Run migrations (pass cwd for project-local migrations) const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd()); diff --git a/packages/coding-agent/src/modes/interactive/components/config-selector.ts b/packages/coding-agent/src/modes/interactive/components/config-selector.ts new file mode 100644 index 00000000..f6c5f1c8 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/config-selector.ts @@ -0,0 +1,575 @@ +/** + * TUI component for managing package resources (enable/disable) + */ + +import { basename, relative } from "node:path"; +import { + type Component, + Container, + type Focusable, + getEditorKeybindings, + Input, + matchesKey, + Spacer, + truncateToWidth, + visibleWidth, +} from "@mariozechner/pi-tui"; +import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.js"; +import type { PackageSource, SettingsManager } from "../../../core/settings-manager.js"; +import { theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; +import { rawKeyHint } from "./keybinding-hints.js"; + +type ResourceType = "extensions" | "skills" | "prompts" | "themes"; + +const RESOURCE_TYPE_LABELS: Record = { + extensions: "Extensions", + skills: "Skills", + prompts: "Prompts", + themes: "Themes", +}; + +interface ResourceItem { + path: string; + enabled: boolean; + metadata: PathMetadata; + resourceType: ResourceType; + displayName: string; + groupKey: string; + subgroupKey: string; +} + +interface ResourceSubgroup { + type: ResourceType; + label: string; + items: ResourceItem[]; +} + +interface ResourceGroup { + key: string; + label: string; + scope: "user" | "project" | "temporary"; + origin: "package" | "top-level"; + source: string; + subgroups: ResourceSubgroup[]; +} + +function getGroupLabel(metadata: PathMetadata): string { + if (metadata.origin === "package") { + return `${metadata.source} (${metadata.scope})`; + } + // Top-level resources + if (metadata.source === "auto") { + return metadata.scope === "user" ? "User (~/.pi/agent/)" : "Project (.pi/)"; + } + return metadata.scope === "user" ? "User settings" : "Project settings"; +} + +function buildGroups(resolved: ResolvedPaths): ResourceGroup[] { + const groupMap = new Map(); + + const addToGroup = (resources: ResolvedResource[], resourceType: ResourceType) => { + for (const res of resources) { + const { path, enabled, metadata } = res; + const groupKey = `${metadata.origin}:${metadata.scope}:${metadata.source}`; + + if (!groupMap.has(groupKey)) { + groupMap.set(groupKey, { + key: groupKey, + label: getGroupLabel(metadata), + scope: metadata.scope, + origin: metadata.origin, + source: metadata.source, + subgroups: [], + }); + } + + const group = groupMap.get(groupKey)!; + const subgroupKey = `${groupKey}:${resourceType}`; + + let subgroup = group.subgroups.find((sg) => sg.type === resourceType); + if (!subgroup) { + subgroup = { + type: resourceType, + label: RESOURCE_TYPE_LABELS[resourceType], + items: [], + }; + group.subgroups.push(subgroup); + } + + subgroup.items.push({ + path, + enabled, + metadata, + resourceType, + displayName: basename(path), + groupKey, + subgroupKey, + }); + } + }; + + addToGroup(resolved.extensions, "extensions"); + addToGroup(resolved.skills, "skills"); + addToGroup(resolved.prompts, "prompts"); + addToGroup(resolved.themes, "themes"); + + // Sort groups: packages first, then top-level; user before project + const groups = Array.from(groupMap.values()); + groups.sort((a, b) => { + if (a.origin !== b.origin) { + return a.origin === "package" ? -1 : 1; + } + if (a.scope !== b.scope) { + return a.scope === "user" ? -1 : 1; + } + return a.source.localeCompare(b.source); + }); + + // Sort subgroups within each group by type order, and items by name + const typeOrder: Record = { extensions: 0, skills: 1, prompts: 2, themes: 3 }; + for (const group of groups) { + group.subgroups.sort((a, b) => typeOrder[a.type] - typeOrder[b.type]); + for (const subgroup of group.subgroups) { + subgroup.items.sort((a, b) => a.displayName.localeCompare(b.displayName)); + } + } + + return groups; +} + +type FlatEntry = + | { type: "group"; group: ResourceGroup } + | { type: "subgroup"; subgroup: ResourceSubgroup; group: ResourceGroup } + | { type: "item"; item: ResourceItem }; + +class ConfigSelectorHeader implements Component { + invalidate(): void {} + + render(width: number): string[] { + const title = theme.bold("Resource Configuration"); + const sep = theme.fg("muted", " ยท "); + const hint = rawKeyHint("space", "toggle") + sep + rawKeyHint("esc", "close"); + const hintWidth = visibleWidth(hint); + const titleWidth = visibleWidth(title); + const spacing = Math.max(1, width - titleWidth - hintWidth); + + return [ + truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""), + theme.fg("muted", "Type to filter resources"), + ]; + } +} + +class ResourceList implements Component, Focusable { + private groups: ResourceGroup[]; + private flatItems: FlatEntry[] = []; + private filteredItems: FlatEntry[] = []; + private selectedIndex = 0; + private searchInput: Input; + private maxVisible = 15; + private settingsManager: SettingsManager; + private cwd: string; + + public onCancel?: () => void; + public onExit?: () => void; + public onToggle?: (item: ResourceItem, newEnabled: boolean) => void; + + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + this.searchInput.focused = value; + } + + constructor(groups: ResourceGroup[], settingsManager: SettingsManager, cwd: string) { + this.groups = groups; + this.settingsManager = settingsManager; + this.cwd = cwd; + this.searchInput = new Input(); + this.buildFlatList(); + this.filteredItems = [...this.flatItems]; + } + + private buildFlatList(): void { + this.flatItems = []; + for (const group of this.groups) { + this.flatItems.push({ type: "group", group }); + for (const subgroup of group.subgroups) { + this.flatItems.push({ type: "subgroup", subgroup, group }); + for (const item of subgroup.items) { + this.flatItems.push({ type: "item", item }); + } + } + } + // Start selection on first item (not header) + this.selectedIndex = this.flatItems.findIndex((e) => e.type === "item"); + if (this.selectedIndex < 0) this.selectedIndex = 0; + } + + private findNextItem(fromIndex: number, direction: 1 | -1): number { + let idx = fromIndex + direction; + while (idx >= 0 && idx < this.filteredItems.length) { + if (this.filteredItems[idx].type === "item") { + return idx; + } + idx += direction; + } + return fromIndex; // Stay at current if no item found + } + + private filterItems(query: string): void { + if (!query.trim()) { + this.filteredItems = [...this.flatItems]; + this.selectFirstItem(); + return; + } + + const lowerQuery = query.toLowerCase(); + const matchingItems = new Set(); + const matchingSubgroups = new Set(); + const matchingGroups = new Set(); + + for (const entry of this.flatItems) { + if (entry.type === "item") { + const item = entry.item; + if ( + item.displayName.toLowerCase().includes(lowerQuery) || + item.resourceType.toLowerCase().includes(lowerQuery) || + item.path.toLowerCase().includes(lowerQuery) + ) { + matchingItems.add(item); + } + } + } + + // Find which subgroups and groups contain matching items + for (const group of this.groups) { + for (const subgroup of group.subgroups) { + for (const item of subgroup.items) { + if (matchingItems.has(item)) { + matchingSubgroups.add(subgroup); + matchingGroups.add(group); + } + } + } + } + + this.filteredItems = []; + for (const entry of this.flatItems) { + if (entry.type === "group" && matchingGroups.has(entry.group)) { + this.filteredItems.push(entry); + } else if (entry.type === "subgroup" && matchingSubgroups.has(entry.subgroup)) { + this.filteredItems.push(entry); + } else if (entry.type === "item" && matchingItems.has(entry.item)) { + this.filteredItems.push(entry); + } + } + + this.selectFirstItem(); + } + + private selectFirstItem(): void { + const firstItemIndex = this.filteredItems.findIndex((e) => e.type === "item"); + this.selectedIndex = firstItemIndex >= 0 ? firstItemIndex : 0; + } + + updateItem(item: ResourceItem, enabled: boolean): void { + item.enabled = enabled; + // Update in groups too + for (const group of this.groups) { + for (const subgroup of group.subgroups) { + const found = subgroup.items.find((i) => i.path === item.path && i.resourceType === item.resourceType); + if (found) { + found.enabled = enabled; + return; + } + } + } + } + + invalidate(): void {} + + render(width: number): string[] { + const lines: string[] = []; + + // Search input + lines.push(...this.searchInput.render(width)); + lines.push(""); + + if (this.filteredItems.length === 0) { + lines.push(theme.fg("muted", " No resources found")); + return lines; + } + + // Calculate visible range + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible), + ); + const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length); + + for (let i = startIndex; i < endIndex; i++) { + const entry = this.filteredItems[i]; + const isSelected = i === this.selectedIndex; + + if (entry.type === "group") { + // Main group header (no cursor) + const groupLine = theme.fg("accent", theme.bold(entry.group.label)); + lines.push(truncateToWidth(` ${groupLine}`, width, "")); + } else if (entry.type === "subgroup") { + // Subgroup header (indented, no cursor) + const subgroupLine = theme.fg("muted", entry.subgroup.label); + lines.push(truncateToWidth(` ${subgroupLine}`, width, "")); + } else { + // Resource item (cursor only on items) + const item = entry.item; + const cursor = isSelected ? "> " : " "; + const checkbox = item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]"); + const name = isSelected ? theme.bold(item.displayName) : item.displayName; + lines.push(truncateToWidth(`${cursor} ${checkbox} ${name}`, width, "...")); + } + } + + // Scroll indicator + if (startIndex > 0 || endIndex < this.filteredItems.length) { + lines.push(theme.fg("dim", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`)); + } + + return lines; + } + + handleInput(data: string): void { + const kb = getEditorKeybindings(); + + if (kb.matches(data, "selectUp")) { + this.selectedIndex = this.findNextItem(this.selectedIndex, -1); + return; + } + if (kb.matches(data, "selectDown")) { + this.selectedIndex = this.findNextItem(this.selectedIndex, 1); + return; + } + if (kb.matches(data, "selectPageUp")) { + // Jump up by maxVisible, then find nearest item + let target = Math.max(0, this.selectedIndex - this.maxVisible); + while (target < this.filteredItems.length && this.filteredItems[target].type !== "item") { + target++; + } + if (target < this.filteredItems.length) { + this.selectedIndex = target; + } + return; + } + if (kb.matches(data, "selectPageDown")) { + // Jump down by maxVisible, then find nearest item + let target = Math.min(this.filteredItems.length - 1, this.selectedIndex + this.maxVisible); + while (target >= 0 && this.filteredItems[target].type !== "item") { + target--; + } + if (target >= 0) { + this.selectedIndex = target; + } + return; + } + if (kb.matches(data, "selectCancel")) { + this.onCancel?.(); + return; + } + if (matchesKey(data, "ctrl+c")) { + this.onExit?.(); + return; + } + if (data === " " || kb.matches(data, "selectConfirm")) { + const entry = this.filteredItems[this.selectedIndex]; + if (entry?.type === "item") { + const newEnabled = !entry.item.enabled; + this.toggleResource(entry.item, newEnabled); + this.updateItem(entry.item, newEnabled); + this.onToggle?.(entry.item, newEnabled); + } + return; + } + + // Pass to search input + this.searchInput.handleInput(data); + this.filterItems(this.searchInput.getValue()); + } + + private toggleResource(item: ResourceItem, enabled: boolean): void { + if (item.metadata.origin === "top-level") { + this.toggleTopLevelResource(item, enabled); + } else { + this.togglePackageResource(item, enabled); + } + } + + private toggleTopLevelResource(item: ResourceItem, enabled: boolean): void { + const scope = item.metadata.scope as "user" | "project"; + const settings = + scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings(); + + const arrayKey = item.resourceType as "extensions" | "skills" | "prompts" | "themes"; + const current = (settings[arrayKey] ?? []) as string[]; + + // Generate pattern for this resource + const pattern = this.getResourcePattern(item); + const disablePattern = `!${pattern}`; + + // Filter out existing patterns for this resource + const updated = current.filter((p) => { + const stripped = p.startsWith("!") || p.startsWith("+") ? p.slice(1) : p; + return stripped !== pattern; + }); + + if (!enabled) { + // Add !pattern to disable + updated.push(disablePattern); + } + // For enabling, just remove the !pattern (done above) + + if (scope === "project") { + if (arrayKey === "extensions") { + this.settingsManager.setProjectExtensionPaths(updated); + } else if (arrayKey === "skills") { + this.settingsManager.setProjectSkillPaths(updated); + } else if (arrayKey === "prompts") { + this.settingsManager.setProjectPromptTemplatePaths(updated); + } else if (arrayKey === "themes") { + this.settingsManager.setProjectThemePaths(updated); + } + } else { + if (arrayKey === "extensions") { + this.settingsManager.setExtensionPaths(updated); + } else if (arrayKey === "skills") { + this.settingsManager.setSkillPaths(updated); + } else if (arrayKey === "prompts") { + this.settingsManager.setPromptTemplatePaths(updated); + } else if (arrayKey === "themes") { + this.settingsManager.setThemePaths(updated); + } + } + } + + private togglePackageResource(item: ResourceItem, enabled: boolean): void { + const scope = item.metadata.scope as "user" | "project"; + const settings = + scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings(); + + const packages = [...(settings.packages ?? [])] as PackageSource[]; + const pkgIndex = packages.findIndex((pkg) => { + const source = typeof pkg === "string" ? pkg : pkg.source; + return source === item.metadata.source; + }); + + if (pkgIndex === -1) return; + + let pkg = packages[pkgIndex]; + + // Convert string to object form if needed + if (typeof pkg === "string") { + pkg = { source: pkg }; + packages[pkgIndex] = pkg; + } + + // Get the resource array for this type + const arrayKey = item.resourceType as "extensions" | "skills" | "prompts" | "themes"; + const current = (pkg[arrayKey] ?? []) as string[]; + + // Generate pattern relative to package root + const pattern = this.getPackageResourcePattern(item); + const disablePattern = `!${pattern}`; + + // Filter out existing patterns for this resource + const updated = current.filter((p) => { + const stripped = p.startsWith("!") || p.startsWith("+") ? p.slice(1) : p; + return stripped !== pattern; + }); + + if (!enabled) { + updated.push(disablePattern); + } + // For enabling, just remove the !pattern (done above) + + (pkg as Record)[arrayKey] = updated.length > 0 ? updated : undefined; + + // Clean up empty filter object + const hasFilters = ["extensions", "skills", "prompts", "themes"].some( + (k) => (pkg as Record)[k] !== undefined, + ); + if (!hasFilters) { + packages[pkgIndex] = (pkg as { source: string }).source; + } + + if (scope === "project") { + this.settingsManager.setProjectPackages(packages); + } else { + this.settingsManager.setPackages(packages); + } + } + + private getResourcePattern(item: ResourceItem): string { + // Try relative path from cwd first + const rel = relative(this.cwd, item.path); + if (!rel.startsWith("..") && !rel.startsWith("/")) { + return rel; + } + // Fall back to basename + return basename(item.path); + } + + private getPackageResourcePattern(item: ResourceItem): string { + // For package resources, use just the filename + return basename(item.path); + } +} + +export class ConfigSelectorComponent extends Container implements Focusable { + private resourceList: ResourceList; + + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + this.resourceList.focused = value; + } + + constructor( + resolvedPaths: ResolvedPaths, + settingsManager: SettingsManager, + cwd: string, + onClose: () => void, + onExit: () => void, + requestRender: () => void, + ) { + super(); + + const groups = buildGroups(resolvedPaths); + + // Add header + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new ConfigSelectorHeader()); + this.addChild(new Spacer(1)); + + // Resource list + this.resourceList = new ResourceList(groups, settingsManager, cwd); + this.resourceList.onCancel = onClose; + this.resourceList.onExit = onExit; + this.resourceList.onToggle = () => requestRender(); + this.addChild(this.resourceList); + + // Bottom border + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + } + + getResourceList(): ResourceList { + return this.resourceList; + } +}