pi-config: interactive picker for /config-add and /config-remove
Adds AssetPickerOverlay (arrow nav, type-to-filter, kind badge, description snippet, active/global marks). /config-add and /config-remove open the picker when called without a concrete name; 2-arg forms unchanged. Also filters node_modules out of listAvailable.
This commit is contained in:
@@ -1,13 +1,17 @@
|
|||||||
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import {
|
||||||
|
AssetPickerOverlay,
|
||||||
|
type AssetItem,
|
||||||
|
type PickerMode,
|
||||||
|
type ResourceKind,
|
||||||
|
} from "./picker.ts";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type ResourceKind = "skill" | "ext";
|
|
||||||
|
|
||||||
interface PackageFilter {
|
interface PackageFilter {
|
||||||
source: string;
|
source: string;
|
||||||
extensions?: string[];
|
extensions?: string[];
|
||||||
@@ -52,7 +56,7 @@ function listAvailable(kind: ResourceKind): string[] {
|
|||||||
if (!fs.existsSync(dir)) return [];
|
if (!fs.existsSync(dir)) return [];
|
||||||
return fs
|
return fs
|
||||||
.readdirSync(dir, { withFileTypes: true })
|
.readdirSync(dir, { withFileTypes: true })
|
||||||
.filter((d) => d.isDirectory())
|
.filter((d) => d.isDirectory() && d.name !== "node_modules" && !d.name.startsWith("."))
|
||||||
.map((d) => d.name)
|
.map((d) => d.name)
|
||||||
.sort();
|
.sort();
|
||||||
}
|
}
|
||||||
@@ -99,6 +103,254 @@ function resolveSkillPath(name: string): string | undefined {
|
|||||||
return `skills/${name}`;
|
return `skills/${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Asset metadata (descriptions + active/global state) for the picker
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const AGENT_HOME = process.env.PI_CODING_AGENT_DIR ?? path.join(process.env.HOME!, ".pi", "agent");
|
||||||
|
const GLOBAL_SETTINGS_PATH = path.join(AGENT_HOME, "settings.json");
|
||||||
|
|
||||||
|
function nameFromEntry(entry: string, kind: ResourceKind): string | undefined {
|
||||||
|
const parts = entry.split("/");
|
||||||
|
const idx = parts.indexOf(kindToDir(kind));
|
||||||
|
if (idx === -1 || idx + 1 >= parts.length) return undefined;
|
||||||
|
return parts[idx + 1] || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectActiveNames(settings: PiSettings): Record<ResourceKind, Set<string>> {
|
||||||
|
const out: Record<ResourceKind, Set<string>> = { skill: new Set(), ext: new Set() };
|
||||||
|
for (const p of settings.packages ?? []) {
|
||||||
|
if (typeof p !== "object" || p === null) continue;
|
||||||
|
for (const e of p.skills ?? []) {
|
||||||
|
const n = nameFromEntry(e, "skill");
|
||||||
|
if (n) out.skill.add(n);
|
||||||
|
}
|
||||||
|
for (const e of p.extensions ?? []) {
|
||||||
|
const n = nameFromEntry(e, "ext");
|
||||||
|
if (n) out.ext.add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadGlobalSettings(): PiSettings {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(GLOBAL_SETTINGS_PATH)) {
|
||||||
|
return JSON.parse(fs.readFileSync(GLOBAL_SETTINGS_PATH, "utf-8")) as PiSettings;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the `description:` from a skill's SKILL.md frontmatter (block scalars included). */
|
||||||
|
function readSkillDescription(name: string): string {
|
||||||
|
const file = path.join(AGENTS_DIR, "skills", name, "SKILL.md");
|
||||||
|
if (!fs.existsSync(file)) return "";
|
||||||
|
try {
|
||||||
|
const text = fs.readFileSync(file, "utf-8");
|
||||||
|
const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||||
|
if (!fm) return "";
|
||||||
|
const lines = fm[1].split(/\r?\n/);
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
const m = lines[i].match(/^description:\s*(.*)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
let val = m[1].trim();
|
||||||
|
if (/^[|>][-+]?$/.test(val)) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (let j = i + 1; j < lines.length; j += 1) {
|
||||||
|
if (/^[A-Za-z0-9_-]+:/.test(lines[j])) break;
|
||||||
|
parts.push(lines[j].trim());
|
||||||
|
}
|
||||||
|
val = parts.join(" ").trim();
|
||||||
|
}
|
||||||
|
return val.replace(/^["']|["']$/g, "").trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort description for an extension: package.json `description`, else first comment line. */
|
||||||
|
function readExtDescription(name: string): string {
|
||||||
|
const dir = path.join(AGENTS_DIR, "extensions", name);
|
||||||
|
const pkgPath = path.join(dir, "package.json");
|
||||||
|
if (fs.existsSync(pkgPath)) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { description?: unknown };
|
||||||
|
if (typeof pkg.description === "string" && pkg.description.trim()) {
|
||||||
|
return pkg.description.trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const fn of ["index.ts", "index.js"]) {
|
||||||
|
const f = path.join(dir, fn);
|
||||||
|
if (!fs.existsSync(f)) continue;
|
||||||
|
try {
|
||||||
|
for (const ln of fs.readFileSync(f, "utf-8").split(/\r?\n/).slice(0, 25)) {
|
||||||
|
const t = ln.trim();
|
||||||
|
if (t.startsWith("//")) {
|
||||||
|
const c = t.replace(/^\/\/\/?\s*/, "").trim();
|
||||||
|
if (c && !c.startsWith("!")) return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Last resort: first prose line of README.md.
|
||||||
|
const readme = path.join(dir, "README.md");
|
||||||
|
if (fs.existsSync(readme)) {
|
||||||
|
try {
|
||||||
|
for (const ln of fs.readFileSync(readme, "utf-8").split(/\r?\n/)) {
|
||||||
|
const t = ln.trim();
|
||||||
|
if (!t || /^[#<!\[|>]/.test(t) || t.startsWith("---")) continue;
|
||||||
|
return t.replace(/[*_`]/g, "").trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gather every asset in ~/.agents with its metadata, sorted useful-first. */
|
||||||
|
function gatherAssets(cwd: string): AssetItem[] {
|
||||||
|
const projectActive = collectActiveNames(loadSettings(cwd));
|
||||||
|
const globalActive = collectActiveNames(loadGlobalSettings());
|
||||||
|
const items: AssetItem[] = [];
|
||||||
|
for (const kind of ["skill", "ext"] as ResourceKind[]) {
|
||||||
|
for (const name of listAvailable(kind)) {
|
||||||
|
const entryPaths =
|
||||||
|
kind === "skill"
|
||||||
|
? [resolveSkillPath(name)].filter((p): p is string => Boolean(p))
|
||||||
|
: resolveExtensionPaths(name) ?? [];
|
||||||
|
items.push({
|
||||||
|
kind,
|
||||||
|
name,
|
||||||
|
description: kind === "skill" ? readSkillDescription(name) : readExtDescription(name),
|
||||||
|
entryPaths,
|
||||||
|
activeInProject: projectActive[kind].has(name),
|
||||||
|
activeGlobally: globalActive[kind].has(name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a.activeInProject !== b.activeInProject) return a.activeInProject ? 1 : -1;
|
||||||
|
if (a.kind !== b.kind) return a.kind === "ext" ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared add/remove + picker wiring
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function addAsset(kind: ResourceKind, name: string, ctx: ExtensionCommandContext): void {
|
||||||
|
const available = listAvailable(kind);
|
||||||
|
if (!available.includes(name)) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`${kind} '${name}' not found in ~/.agents/${kindToDir(kind)}/. Available: ${available.join(", ") || "(none)"}`,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedPaths: string[];
|
||||||
|
if (kind === "skill") {
|
||||||
|
const p = resolveSkillPath(name);
|
||||||
|
if (!p) {
|
||||||
|
ctx.ui.notify(`Could not resolve path for skill '${name}'`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolvedPaths = [p];
|
||||||
|
} else {
|
||||||
|
const paths = resolveExtensionPaths(name);
|
||||||
|
if (!paths || paths.length === 0) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Could not resolve entry point for extension '${name}'. Check ~/.agents/extensions/${name}/ for structure.`,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolvedPaths = paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = loadSettings(ctx.cwd);
|
||||||
|
const pkg = ensureAgentsPackage(settings);
|
||||||
|
const key = kind === "skill" ? "skills" : "extensions";
|
||||||
|
const arr: string[] = (pkg[key] as string[]) ?? [];
|
||||||
|
|
||||||
|
if (resolvedPaths.every((rp) => arr.includes(rp))) {
|
||||||
|
ctx.ui.notify(`${kind} '${name}' is already active.`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rp of resolvedPaths) {
|
||||||
|
if (!arr.includes(rp)) arr.push(rp);
|
||||||
|
}
|
||||||
|
pkg[key] = arr;
|
||||||
|
saveSettings(ctx.cwd, settings);
|
||||||
|
ctx.ui.notify(`✓ Added ${kind} '${name}' (${resolvedPaths.join(", ")}). Run /reload to apply.`, "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAsset(kind: ResourceKind, name: string, ctx: ExtensionCommandContext): void {
|
||||||
|
const settings = loadSettings(ctx.cwd);
|
||||||
|
const pkg = findAgentsPackage(settings);
|
||||||
|
if (!pkg) {
|
||||||
|
ctx.ui.notify("No ~/.agents package entry found in settings.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = kind === "skill" ? "skills" : "extensions";
|
||||||
|
const arr: string[] = (pkg[key] as string[]) ?? [];
|
||||||
|
const filtered = arr.filter((entry) => !entry.split("/").includes(name));
|
||||||
|
|
||||||
|
if (filtered.length === arr.length) {
|
||||||
|
ctx.ui.notify(`${kind} '${name}' was not found in the active list.`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg[key] = filtered;
|
||||||
|
saveSettings(ctx.cwd, settings);
|
||||||
|
ctx.ui.notify(`✓ Removed ${kind} '${name}'. Run /reload to apply.`, "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAssetPicker(
|
||||||
|
ctx: ExtensionCommandContext,
|
||||||
|
mode: PickerMode,
|
||||||
|
kindFilter?: ResourceKind,
|
||||||
|
initialQuery = "",
|
||||||
|
): Promise<void> {
|
||||||
|
let items = gatherAssets(ctx.cwd);
|
||||||
|
if (mode === "remove") items = items.filter((i) => i.activeInProject);
|
||||||
|
if (kindFilter) items = items.filter((i) => i.kind === kindFilter);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
mode === "remove"
|
||||||
|
? "Nothing from ~/.agents is active in this project."
|
||||||
|
: "No skills or extensions found in ~/.agents.",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = await ctx.ui.custom<AssetItem | undefined>(
|
||||||
|
(_tui, theme, keybindings, done) =>
|
||||||
|
new AssetPickerOverlay(theme, keybindings, items, mode, initialQuery, done),
|
||||||
|
);
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
if (mode === "add") addAsset(selected.kind, selected.name, ctx);
|
||||||
|
else removeAsset(selected.kind, selected.name, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
function loadSettings(cwd: string): PiSettings {
|
function loadSettings(cwd: string): PiSettings {
|
||||||
const p = projectSettingsPath(cwd);
|
const p = projectSettingsPath(cwd);
|
||||||
if (!fs.existsSync(p)) return {};
|
if (!fs.existsSync(p)) return {};
|
||||||
@@ -139,114 +391,46 @@ export default function (pi: ExtensionAPI) {
|
|||||||
// ── /config-add skill|ext <name> ──────────────────────────────
|
// ── /config-add skill|ext <name> ──────────────────────────────
|
||||||
pi.registerCommand("config-add", {
|
pi.registerCommand("config-add", {
|
||||||
description:
|
description:
|
||||||
"Add a skill or extension from ~/.agents to this project. Usage: /config-add skill <name> | /config-add ext <name>",
|
"Add a skill or extension from ~/.agents. Usage: /config-add [skill|ext] [name] — omit the name for an interactive picker",
|
||||||
handler: async (args, ctx: ExtensionCommandContext) => {
|
handler: async (args, ctx: ExtensionCommandContext) => {
|
||||||
const parts = (args ?? "").trim().split(/\s+/);
|
const parts = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
||||||
if (parts.length < 2) {
|
if (parts.length >= 2) {
|
||||||
ctx.ui.notify("Usage: /config-add skill <name> | /config-add ext <name>", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const [kindStr, name] = parts as [string, string];
|
const [kindStr, name] = parts as [string, string];
|
||||||
if (kindStr !== "skill" && kindStr !== "ext") {
|
if (kindStr !== "skill" && kindStr !== "ext") {
|
||||||
ctx.ui.notify("First arg must be 'skill' or 'ext'", "error");
|
ctx.ui.notify("First arg must be 'skill' or 'ext'", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const kind: ResourceKind = kindStr;
|
addAsset(kindStr as ResourceKind, name, ctx);
|
||||||
|
|
||||||
// Check existence + resolve path(s)
|
|
||||||
const available = listAvailable(kind);
|
|
||||||
if (!available.includes(name)) {
|
|
||||||
ctx.ui.notify(
|
|
||||||
`${kind} '${name}' not found in ~/.agents/${kindToDir(kind)}/. Available: ${available.join(", ") || "(none)"}`,
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (parts.length === 1 && (parts[0] === "skill" || parts[0] === "ext")) {
|
||||||
let resolvedPaths: string[];
|
await openAssetPicker(ctx, "add", parts[0] as ResourceKind);
|
||||||
if (kind === "skill") {
|
|
||||||
const p = resolveSkillPath(name);
|
|
||||||
if (!p) {
|
|
||||||
ctx.ui.notify(`Could not resolve path for skill '${name}'`, "error");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolvedPaths = [p];
|
await openAssetPicker(ctx, "add", undefined, parts[0] ?? "");
|
||||||
} else {
|
|
||||||
const paths = resolveExtensionPaths(name);
|
|
||||||
if (!paths || paths.length === 0) {
|
|
||||||
ctx.ui.notify(
|
|
||||||
`Could not resolve entry point for extension '${name}'. Check ~/.agents/extensions/${name}/ for structure.`,
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolvedPaths = paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = loadSettings(ctx.cwd);
|
|
||||||
const pkg = ensureAgentsPackage(settings);
|
|
||||||
const key = kind === "skill" ? "skills" : "extensions";
|
|
||||||
const arr: string[] = (pkg[key] as string[]) ?? [];
|
|
||||||
|
|
||||||
const alreadyPresent = resolvedPaths.every((rp) => arr.includes(rp));
|
|
||||||
if (alreadyPresent) {
|
|
||||||
ctx.ui.notify(`${kind} '${name}' is already active.`, "info");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add any that aren't already there
|
|
||||||
for (const rp of resolvedPaths) {
|
|
||||||
if (!arr.includes(rp)) arr.push(rp);
|
|
||||||
}
|
|
||||||
pkg[key] = arr;
|
|
||||||
saveSettings(ctx.cwd, settings);
|
|
||||||
const pathsStr = resolvedPaths.join(", ");
|
|
||||||
ctx.ui.notify(`✓ Added ${kind} '${name}' (${pathsStr}). Run /reload to apply.`, "success");
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── /config-remove skill|ext <name> ──────────────────────────
|
// ── /config-remove skill|ext <name> ──────────────────────────
|
||||||
pi.registerCommand("config-remove", {
|
pi.registerCommand("config-remove", {
|
||||||
description:
|
description:
|
||||||
"Remove a skill or extension from this project. Usage: /config-remove skill <name> | /config-remove ext <name>",
|
"Remove a skill or extension from this project. Usage: /config-remove [skill|ext] [name] — omit the name for an interactive picker",
|
||||||
handler: async (args, ctx: ExtensionCommandContext) => {
|
handler: async (args, ctx: ExtensionCommandContext) => {
|
||||||
const parts = (args ?? "").trim().split(/\s+/);
|
const parts = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
||||||
if (parts.length < 2) {
|
if (parts.length >= 2) {
|
||||||
ctx.ui.notify("Usage: /config-remove skill <name> | /config-remove ext <name>", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const [kindStr, name] = parts as [string, string];
|
const [kindStr, name] = parts as [string, string];
|
||||||
if (kindStr !== "skill" && kindStr !== "ext") {
|
if (kindStr !== "skill" && kindStr !== "ext") {
|
||||||
ctx.ui.notify("First arg must be 'skill' or 'ext'", "error");
|
ctx.ui.notify("First arg must be 'skill' or 'ext'", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const kind: ResourceKind = kindStr;
|
removeAsset(kindStr as ResourceKind, name, ctx);
|
||||||
|
|
||||||
const settings = loadSettings(ctx.cwd);
|
|
||||||
const pkg = findAgentsPackage(settings);
|
|
||||||
if (!pkg) {
|
|
||||||
ctx.ui.notify("No ~/.agents package entry found in settings.", "error");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (parts.length === 1 && (parts[0] === "skill" || parts[0] === "ext")) {
|
||||||
const key = kind === "skill" ? "skills" : "extensions";
|
await openAssetPicker(ctx, "remove", parts[0] as ResourceKind);
|
||||||
const arr: string[] = (pkg[key] as string[]) ?? [];
|
|
||||||
|
|
||||||
// Remove any entry that contains "/<name>/" or "/<name>." or ends with "/<name>"
|
|
||||||
// This handles: "skills/caveman", "extensions/pi-config/index.ts", etc.
|
|
||||||
const filtered = arr.filter((entry) => {
|
|
||||||
const parts2 = entry.split("/");
|
|
||||||
return !parts2.includes(name);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filtered.length === arr.length) {
|
|
||||||
ctx.ui.notify(`${kind} '${name}' was not found in the active list.`, "info");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await openAssetPicker(ctx, "remove", undefined, parts[0] ?? "");
|
||||||
pkg[key] = filtered;
|
|
||||||
saveSettings(ctx.cwd, settings);
|
|
||||||
ctx.ui.notify(`✓ Removed ${kind} '${name}'. Run /reload to apply.`, "success");
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
189
extensions/pi-config/picker.ts
Normal file
189
extensions/pi-config/picker.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Asset picker overlay for the pi-config extension.
|
||||||
|
*
|
||||||
|
* A small modal list of skills/extensions from ~/.agents with arrow-key
|
||||||
|
* navigation, type-to-filter, a description snippet, and active/global marks.
|
||||||
|
* Used by /config-add and /config-remove when invoked without a concrete name.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Component } from "@mariozechner/pi-tui";
|
||||||
|
import { truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
||||||
|
import type { KeybindingsManager, Theme } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export type ResourceKind = "skill" | "ext";
|
||||||
|
export type PickerMode = "add" | "remove";
|
||||||
|
|
||||||
|
export interface AssetItem {
|
||||||
|
kind: ResourceKind;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
entryPaths: string[];
|
||||||
|
activeInProject: boolean;
|
||||||
|
activeGlobally: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssetPickerOverlay implements Component {
|
||||||
|
private theme: Theme;
|
||||||
|
private keybindings: KeybindingsManager;
|
||||||
|
private items: AssetItem[];
|
||||||
|
private mode: PickerMode;
|
||||||
|
private done: (result: AssetItem | undefined) => void;
|
||||||
|
private query: string;
|
||||||
|
private selectedIndex = 0;
|
||||||
|
private readonly maxVisible = 9;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
theme: Theme,
|
||||||
|
keybindings: KeybindingsManager,
|
||||||
|
items: AssetItem[],
|
||||||
|
mode: PickerMode,
|
||||||
|
initialQuery: string,
|
||||||
|
done: (result: AssetItem | undefined) => void,
|
||||||
|
) {
|
||||||
|
this.theme = theme;
|
||||||
|
this.keybindings = keybindings;
|
||||||
|
this.items = items;
|
||||||
|
this.mode = mode;
|
||||||
|
this.query = initialQuery;
|
||||||
|
this.done = done;
|
||||||
|
}
|
||||||
|
|
||||||
|
private filtered(): AssetItem[] {
|
||||||
|
const q = this.query.trim().toLowerCase();
|
||||||
|
if (!q) return this.items;
|
||||||
|
return this.items.filter(
|
||||||
|
(i) =>
|
||||||
|
i.name.toLowerCase().includes(q) ||
|
||||||
|
i.kind.toLowerCase().includes(q) ||
|
||||||
|
i.description.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private move(delta: number): void {
|
||||||
|
const n = this.filtered().length;
|
||||||
|
if (n === 0) return;
|
||||||
|
this.selectedIndex = (this.selectedIndex + delta + n) % n;
|
||||||
|
}
|
||||||
|
|
||||||
|
private confirm(): void {
|
||||||
|
const item = this.filtered()[this.selectedIndex];
|
||||||
|
if (item) this.done(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(): void {}
|
||||||
|
|
||||||
|
handleInput(data: string): void {
|
||||||
|
const kb = this.keybindings;
|
||||||
|
if (kb.matches(data, "tui.select.cancel")) {
|
||||||
|
this.done(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kb.matches(data, "tui.select.up")) {
|
||||||
|
this.move(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kb.matches(data, "tui.select.down")) {
|
||||||
|
this.move(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kb.matches(data, "tui.select.confirm")) {
|
||||||
|
this.confirm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data === "\u007f" || data === "\b") {
|
||||||
|
if (this.query.length > 0) {
|
||||||
|
this.query = this.query.slice(0, -1);
|
||||||
|
this.selectedIndex = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.length === 1 && data >= " " && data <= "~") {
|
||||||
|
this.query += data;
|
||||||
|
this.selectedIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(width: number): string[] {
|
||||||
|
const theme = this.theme;
|
||||||
|
const innerWidth = Math.max(40, Math.min(width - 2, 88));
|
||||||
|
const contentWidth = innerWidth - 2;
|
||||||
|
const border = (t: string) => theme.fg("accent", t);
|
||||||
|
const row = (text = "") => {
|
||||||
|
const clipped = truncateToWidth(text, contentWidth, "…", true);
|
||||||
|
return `${border("│")}${clipped}${border("│")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const list = this.filtered();
|
||||||
|
if (this.selectedIndex >= list.length) {
|
||||||
|
this.selectedIndex = Math.max(0, list.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = this.mode === "add" ? " Add to project " : " Remove from project ";
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(border(`╭${"─".repeat(contentWidth)}╮`));
|
||||||
|
lines.push(row(theme.bold(title)));
|
||||||
|
lines.push(row(` Filter: ${this.query}${theme.fg("accent", "█")}`));
|
||||||
|
lines.push(border(`├${"─".repeat(contentWidth)}┤`));
|
||||||
|
|
||||||
|
const hasActive = list.some((i) => i.activeInProject);
|
||||||
|
const hasGlobal = list.some((i) => i.activeGlobally && !i.activeInProject);
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
lines.push(row(theme.fg("dim", " No matching skills or extensions")));
|
||||||
|
} else {
|
||||||
|
const start = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), list.length - this.maxVisible),
|
||||||
|
);
|
||||||
|
const end = Math.min(start + this.maxVisible, list.length);
|
||||||
|
for (let i = start; i < end; i += 1) {
|
||||||
|
const item = list[i];
|
||||||
|
const selected = i === this.selectedIndex;
|
||||||
|
const pointer = selected ? theme.fg("accent", "→ ") : " ";
|
||||||
|
const badge = theme.fg("dim", `[${item.kind}]`.padEnd(7));
|
||||||
|
const mark = item.activeInProject
|
||||||
|
? " " + theme.fg("accent", "✓")
|
||||||
|
: item.activeGlobally
|
||||||
|
? theme.fg("dim", " ·")
|
||||||
|
: "";
|
||||||
|
const name = selected ? theme.fg("accent", item.name) : item.name;
|
||||||
|
lines.push(row(`${pointer}${badge}${name}${mark}`));
|
||||||
|
}
|
||||||
|
if (start > 0 || end < list.length) {
|
||||||
|
lines.push(row(theme.fg("dim", ` ${this.selectedIndex + 1}/${list.length}`)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Details for the highlighted item.
|
||||||
|
const sel = list[this.selectedIndex];
|
||||||
|
lines.push(border(`├${"─".repeat(contentWidth)}┤`));
|
||||||
|
if (sel) {
|
||||||
|
const descLines = sel.description
|
||||||
|
? wrapTextWithAnsi(sel.description, Math.max(10, contentWidth - 2)).slice(0, 3)
|
||||||
|
: [theme.fg("dim", "(no description)")];
|
||||||
|
for (const dl of descLines) lines.push(row(" " + dl));
|
||||||
|
const status = sel.activeInProject
|
||||||
|
? "active in this project"
|
||||||
|
: sel.activeGlobally
|
||||||
|
? "active globally"
|
||||||
|
: "available";
|
||||||
|
lines.push(row(theme.fg("dim", ` status: ${status}`)));
|
||||||
|
lines.push(row(theme.fg("dim", ` entry: ${sel.entryPaths.join(", ")}`)));
|
||||||
|
} else {
|
||||||
|
lines.push(row(theme.fg("dim", " —")));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(border(`├${"─".repeat(contentWidth)}┤`));
|
||||||
|
const up = this.keybindings.getKeys("tui.select.up").join("/");
|
||||||
|
const down = this.keybindings.getKeys("tui.select.down").join("/");
|
||||||
|
const enter = this.keybindings.getKeys("tui.select.confirm").join("/");
|
||||||
|
const esc = this.keybindings.getKeys("tui.select.cancel").join("/");
|
||||||
|
const action = this.mode === "add" ? "add" : "remove";
|
||||||
|
const legend = `${hasActive ? " ✓ active here" : ""}${hasGlobal ? " · global" : ""}`;
|
||||||
|
lines.push(
|
||||||
|
row(theme.fg("dim", ` ${up}${down}: move • type to filter • ${enter}: ${action} • ${esc}: cancel${legend}`)),
|
||||||
|
);
|
||||||
|
lines.push(border(`╰${"─".repeat(contentWidth)}╯`));
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user