@mariozechner/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
479 lines • 18.6 kB
JavaScript
/**
* TUI component for managing package resources (enable/disable)
*/
import { basename, dirname, join, relative } from "node:path";
import { Container, getKeybindings, Input, matchesKey, Spacer, truncateToWidth, visibleWidth, } from "@mariozechner/pi-tui";
import { CONFIG_DIR_NAME } from "../../../config.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { rawKeyHint } from "./keybinding-hints.js";
const RESOURCE_TYPE_LABELS = {
extensions: "Extensions",
skills: "Skills",
prompts: "Prompts",
themes: "Themes",
};
function getGroupLabel(metadata) {
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) {
const groupMap = new Map();
const addToGroup = (resources, 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;
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 = { 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;
}
class ConfigSelectorHeader {
invalidate() { }
render(width) {
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 {
groups;
flatItems = [];
filteredItems = [];
selectedIndex = 0;
searchInput;
maxVisible = 15;
settingsManager;
cwd;
agentDir;
onCancel;
onExit;
onToggle;
_focused = false;
get focused() {
return this._focused;
}
set focused(value) {
this._focused = value;
this.searchInput.focused = value;
}
constructor(groups, settingsManager, cwd, agentDir) {
this.groups = groups;
this.settingsManager = settingsManager;
this.cwd = cwd;
this.agentDir = agentDir;
this.searchInput = new Input();
this.buildFlatList();
this.filteredItems = [...this.flatItems];
}
buildFlatList() {
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;
}
findNextItem(fromIndex, direction) {
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
}
filterItems(query) {
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();
}
selectFirstItem() {
const firstItemIndex = this.filteredItems.findIndex((e) => e.type === "item");
this.selectedIndex = firstItemIndex >= 0 ? firstItemIndex : 0;
}
updateItem(item, enabled) {
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() { }
render(width) {
const lines = [];
// 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) {
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());
}
toggleResource(item, enabled) {
if (item.metadata.origin === "top-level") {
this.toggleTopLevelResource(item, enabled);
}
else {
this.togglePackageResource(item, enabled);
}
}
toggleTopLevelResource(item, enabled) {
const scope = item.metadata.scope;
const settings = scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const arrayKey = item.resourceType;
const current = (settings[arrayKey] ?? []);
// 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);
}
}
}
togglePackageResource(item, enabled) {
const scope = item.metadata.scope;
const settings = scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const packages = [...(settings.packages ?? [])];
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;
const current = (pkg[arrayKey] ?? []);
// 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[arrayKey] = updated.length > 0 ? updated : undefined;
// Clean up empty filter object
const hasFilters = ["extensions", "skills", "prompts", "themes"].some((k) => pkg[k] !== undefined);
if (!hasFilters) {
packages[pkgIndex] = pkg.source;
}
if (scope === "project") {
this.settingsManager.setProjectPackages(packages);
}
else {
this.settingsManager.setPackages(packages);
}
}
getTopLevelBaseDir(scope) {
return scope === "project" ? join(this.cwd, CONFIG_DIR_NAME) : this.agentDir;
}
getResourcePattern(item) {
const scope = item.metadata.scope;
const baseDir = this.getTopLevelBaseDir(scope);
return relative(baseDir, item.path);
}
getPackageResourcePattern(item) {
const baseDir = item.metadata.baseDir ?? dirname(item.path);
return relative(baseDir, item.path);
}
}
export class ConfigSelectorComponent extends Container {
resourceList;
_focused = false;
get focused() {
return this._focused;
}
set focused(value) {
this._focused = value;
this.resourceList.focused = value;
}
constructor(resolvedPaths, settingsManager, cwd, agentDir, onClose, onExit, requestRender) {
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() {
return this.resourceList;
}
}
//# sourceMappingURL=config-selector.js.map