mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +00:00
feat(coding-agent): add pi config TUI to manage resources
Adds a new 'pi config' command with a TUI to list and toggle package resources (extensions, skills, prompts, themes). - Displays resources grouped by source (packages, user, project) - Subgroups by resource type (Extensions, Skills, Prompts, Themes) - Toggle enabled/disabled state with space - Filter resources by typing - Supports +pattern for force-include, !pattern for exclude - Properly reads exclusion patterns from settings.json fixes #938
This commit is contained in:
parent
ef5149fdf6
commit
150128fd2c
5 changed files with 977 additions and 0 deletions
|
|
@ -184,6 +184,7 @@ ${chalk.bold("Commands:")}
|
|||
${APP_NAME} remove <source> [-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 <name> Provider name (default: google)
|
||||
|
|
|
|||
353
packages/coding-agent/src/cli/config-selector.ts
Normal file
353
packages/coding-agent/src/cli/config-selector.ts
Normal file
|
|
@ -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<ResourceType, RegExp> = {
|
||||
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<string>,
|
||||
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<void> {
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ResourceType, string> = {
|
||||
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<string, ResourceGroup>();
|
||||
|
||||
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<ResourceType, number> = { 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<ResourceItem>();
|
||||
const matchingSubgroups = new Set<ResourceSubgroup>();
|
||||
const matchingGroups = new Set<ResourceGroup>();
|
||||
|
||||
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<string, unknown>)[arrayKey] = updated.length > 0 ? updated : undefined;
|
||||
|
||||
// Clean up empty filter object
|
||||
const hasFilters = ["extensions", "skills", "prompts", "themes"].some(
|
||||
(k) => (pkg as Record<string, unknown>)[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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue