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 * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
AssetPickerOverlay,
|
||||
type AssetItem,
|
||||
type PickerMode,
|
||||
type ResourceKind,
|
||||
} from "./picker.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ResourceKind = "skill" | "ext";
|
||||
|
||||
interface PackageFilter {
|
||||
source: string;
|
||||
extensions?: string[];
|
||||
@@ -52,7 +56,7 @@ function listAvailable(kind: ResourceKind): string[] {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.filter((d) => d.isDirectory() && d.name !== "node_modules" && !d.name.startsWith("."))
|
||||
.map((d) => d.name)
|
||||
.sort();
|
||||
}
|
||||
@@ -99,6 +103,254 @@ function resolveSkillPath(name: string): string | undefined {
|
||||
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 {
|
||||
const p = projectSettingsPath(cwd);
|
||||
if (!fs.existsSync(p)) return {};
|
||||
@@ -139,114 +391,46 @@ export default function (pi: ExtensionAPI) {
|
||||
// ── /config-add skill|ext <name> ──────────────────────────────
|
||||
pi.registerCommand("config-add", {
|
||||
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) => {
|
||||
const parts = (args ?? "").trim().split(/\s+/);
|
||||
if (parts.length < 2) {
|
||||
ctx.ui.notify("Usage: /config-add skill <name> | /config-add ext <name>", "error");
|
||||
return;
|
||||
}
|
||||
const parts = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
const [kindStr, name] = parts as [string, string];
|
||||
if (kindStr !== "skill" && kindStr !== "ext") {
|
||||
ctx.ui.notify("First arg must be 'skill' or 'ext'", "error");
|
||||
return;
|
||||
}
|
||||
const kind: ResourceKind = kindStr;
|
||||
|
||||
// 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",
|
||||
);
|
||||
addAsset(kindStr as ResourceKind, name, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let resolvedPaths: string[];
|
||||
if (kind === "skill") {
|
||||
const p = resolveSkillPath(name);
|
||||
if (!p) {
|
||||
ctx.ui.notify(`Could not resolve path for skill '${name}'`, "error");
|
||||
if (parts.length === 1 && (parts[0] === "skill" || parts[0] === "ext")) {
|
||||
await openAssetPicker(ctx, "add", parts[0] as ResourceKind);
|
||||
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[]) ?? [];
|
||||
|
||||
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");
|
||||
await openAssetPicker(ctx, "add", undefined, parts[0] ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
// ── /config-remove skill|ext <name> ──────────────────────────
|
||||
pi.registerCommand("config-remove", {
|
||||
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) => {
|
||||
const parts = (args ?? "").trim().split(/\s+/);
|
||||
if (parts.length < 2) {
|
||||
ctx.ui.notify("Usage: /config-remove skill <name> | /config-remove ext <name>", "error");
|
||||
return;
|
||||
}
|
||||
const parts = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
const [kindStr, name] = parts as [string, string];
|
||||
if (kindStr !== "skill" && kindStr !== "ext") {
|
||||
ctx.ui.notify("First arg must be 'skill' or 'ext'", "error");
|
||||
return;
|
||||
}
|
||||
const kind: ResourceKind = kindStr;
|
||||
|
||||
const settings = loadSettings(ctx.cwd);
|
||||
const pkg = findAgentsPackage(settings);
|
||||
if (!pkg) {
|
||||
ctx.ui.notify("No ~/.agents package entry found in settings.", "error");
|
||||
removeAsset(kindStr as ResourceKind, name, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = kind === "skill" ? "skills" : "extensions";
|
||||
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");
|
||||
if (parts.length === 1 && (parts[0] === "skill" || parts[0] === "ext")) {
|
||||
await openAssetPicker(ctx, "remove", parts[0] as ResourceKind);
|
||||
return;
|
||||
}
|
||||
|
||||
pkg[key] = filtered;
|
||||
saveSettings(ctx.cwd, settings);
|
||||
ctx.ui.notify(`✓ Removed ${kind} '${name}'. Run /reload to apply.`, "success");
|
||||
await openAssetPicker(ctx, "remove", undefined, parts[0] ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
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