Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
18 kB · 603 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604/** * TUI component for managing package resources (enable/disable) */
import { basename, dirname, join, relative } from "node:path";import { type Component, Container, type Focusable, getKeybindings, Input, matchesKey, Spacer, truncateToWidth, visibleWidth,} from "@earendil-works/pi-tui";import { CONFIG_DIR_NAME } from "../../../config.js";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 === "builtin") { return "Built-in"; } if (metadata.source === "auto") { return metadata.scope === "user" ? `User (~/${CONFIG_DIR_NAME}/)` : `Project (${CONFIG_DIR_NAME}/)`; } 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); }
const fileName = basename(path); const parentFolder = basename(dirname(path)); let displayName: string; if (resourceType === "extensions" && parentFolder !== "extensions") { displayName = `${parentFolder}/${fileName}`; } else if (resourceType === "skills" && fileName === "SKILL.md") { displayName = parentFolder; } else { displayName = fileName; } subgroup.items.push({ path, enabled, metadata, resourceType, displayName, 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 = "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; private agentDir: 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, agentDir: string) { this.groups = groups; this.settingsManager = settingsManager; this.cwd = cwd; this.agentDir = agentDir; 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", 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) { const itemCount = this.filteredItems.filter((e) => e.type === "item").length; const currentItemIndex = this.filteredItems.slice(0, this.selectedIndex).filter((e) => e.type === "item").length + 1; lines.push(theme.fg("dim", ` (${currentItemIndex}/${itemCount})`)); }
return lines; }
handleInput(data: string): void { const kb = getKeybindings();
if (kb.matches(data, "tui.select.up")) { this.selectedIndex = this.findNextItem(this.selectedIndex, -1); return; } if (kb.matches(data, "tui.select.down")) { this.selectedIndex = this.findNextItem(this.selectedIndex, 1); return; } if (kb.matches(data, "tui.select.pageUp")) { // 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, "tui.select.pageDown")) { // 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, "tui.select.cancel")) { this.onCancel?.(); return; } if (matchesKey(data, "ctrl+c")) { this.onExit?.(); return; } if (data === " " || kb.matches(data, "tui.select.confirm")) { 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}`; const enablePattern = `+${pattern}`;
// Filter out existing patterns for this resource const updated = current.filter((p) => { const stripped = p.startsWith("!") || p.startsWith("+") || p.startsWith("-") ? p.slice(1) : p; return stripped !== pattern; });
if (enabled) { updated.push(enablePattern); } else { updated.push(disablePattern); }
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}`; const enablePattern = `+${pattern}`;
// Filter out existing patterns for this resource const updated = current.filter((p) => { const stripped = p.startsWith("!") || p.startsWith("+") || p.startsWith("-") ? p.slice(1) : p; return stripped !== pattern; });
if (enabled) { updated.push(enablePattern); } else { updated.push(disablePattern); }
(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 getTopLevelBaseDir(scope: "user" | "project"): string { return scope === "project" ? join(this.cwd, CONFIG_DIR_NAME) : this.agentDir; }
private getResourcePattern(item: ResourceItem): string { // Built-in resources live under the package install dir; their override // patterns are matched relative to metadata.baseDir, not the config dir. if (item.metadata.source === "builtin" && item.metadata.baseDir) { return relative(item.metadata.baseDir, item.path); } const scope = item.metadata.scope as "user" | "project"; const baseDir = this.getTopLevelBaseDir(scope); return relative(baseDir, item.path); }
private getPackageResourcePattern(item: ResourceItem): string { const baseDir = item.metadata.baseDir ?? dirname(item.path); return relative(baseDir, 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, agentDir: 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, agentDir); 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; }}