pi-hermes-memory 0.7.7 → 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,58 +1,1233 @@
1
1
  /**
2
- * Skills command — /memory-skills lists all agent-created skills.
2
+ * Skills command — /memory-skills opens an interactive skills manager.
3
3
  */
4
4
 
5
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { createHash } from "node:crypto";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
6
9
  import { SkillStore } from "../store/skill-store.js";
10
+ import type { SkillIndex, SkillResult, SkillScope } from "../types.js";
11
+ import {
12
+ Input,
13
+ Key,
14
+ fuzzyFilter,
15
+ matchesKey,
16
+ truncateToWidth,
17
+ visibleWidth,
18
+ wrapTextWithAnsi,
19
+ type Focusable,
20
+ type TUI,
21
+ } from "@earendil-works/pi-tui";
7
22
 
8
- export function registerSkillsCommand(pi: ExtensionAPI, store: SkillStore): void {
9
- pi.registerCommand("memory-skills", {
10
- description: "List all agent-created skills (procedural memory)",
11
- handler: async (_args, ctx) => {
12
- const skills = await store.loadIndex();
13
- const globalSkills = skills.filter((skill) => skill.scope === "global");
14
- const projectSkills = skills.filter((skill) => skill.scope === "project");
15
- const projectName = store.getProjectName();
23
+ export const MEMORY_SKILLS_KEYMAP = {
24
+ moveGlobal: "g",
25
+ moveProject: "p",
26
+ deleteSelected: "d",
27
+ selectAllFiltered: "a",
28
+ clearSelection: "n",
29
+ focusSearch: "/",
30
+ openFilters: "f",
31
+ toggleSelection: "space",
32
+ switchFocus: "tab",
33
+ close: "esc",
34
+ } as const;
35
+
36
+ export type SkillRowCategory = "G" | "P" | "E";
37
+
38
+ export interface SkillModalRow {
39
+ skillId: string;
40
+ scope?: SkillScope;
41
+ category: SkillRowCategory;
42
+ mutable: boolean;
43
+ name: string;
44
+ displayName: string;
45
+ description: string;
46
+ path: string;
47
+ displayPath: string;
48
+ projectName?: string;
49
+ selected: boolean;
50
+ searchText: string;
51
+ }
52
+
53
+ interface LoadedSkillRow {
54
+ name: string;
55
+ displayName: string;
56
+ description: string;
57
+ path: string;
58
+ displayPath: string;
59
+ sourceScope?: string;
60
+ sourceOrigin?: string;
61
+ sourceLabel?: string;
62
+ }
63
+
64
+ interface SkillCommandInfo {
65
+ name: string;
66
+ description?: string;
67
+ source?: string;
68
+ sourceInfo?: {
69
+ path?: string;
70
+ scope?: string;
71
+ source?: string;
72
+ origin?: string;
73
+ baseDir?: string;
74
+ };
75
+ }
76
+
77
+ interface SkillCategoryFilters {
78
+ global: boolean;
79
+ project: boolean;
80
+ external: boolean;
81
+ }
82
+
83
+ function isRecord(value: unknown): value is Record<string, unknown> {
84
+ return typeof value === "object" && value !== null;
85
+ }
86
+
87
+ function getStringField(value: unknown): string | undefined {
88
+ return typeof value === "string" ? value : undefined;
89
+ }
90
+
91
+ const DEFAULT_SKILL_FILTERS: SkillCategoryFilters = {
92
+ global: true,
93
+ project: true,
94
+ external: true,
95
+ };
96
+
97
+ function cloneFilters(filters: SkillCategoryFilters): SkillCategoryFilters {
98
+ return {
99
+ global: filters.global,
100
+ project: filters.project,
101
+ external: filters.external,
102
+ };
103
+ }
104
+
105
+ function ensureValidFilters(filters: SkillCategoryFilters): SkillCategoryFilters {
106
+ if (filters.global || filters.project || filters.external) return filters;
107
+ return { ...DEFAULT_SKILL_FILTERS };
108
+ }
109
+
110
+ function filtersLabel(filters: SkillCategoryFilters): string {
111
+ const active: string[] = [];
112
+ if (filters.global) active.push("[G]");
113
+ if (filters.project) active.push("[P]");
114
+ if (filters.external) active.push("[E]");
115
+ return active.length > 0 ? active.join(" ") : "(none)";
116
+ }
117
+
118
+ function normalizePathForKey(inputPath: string): string {
119
+ const resolved = path.resolve(inputPath);
120
+ const normalized = resolved.replace(/\\/g, "/");
121
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
122
+ }
123
+
124
+ export function formatSkillPath(inputPath: string): string {
125
+ const absolutePath = path.resolve(inputPath);
126
+ const home = os.homedir();
127
+ const relative = path.relative(home, absolutePath);
128
+ const underHome = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
16
129
 
17
- const lines: string[] = [];
130
+ if (!underHome) return absolutePath;
131
+ if (relative === "") return "~";
132
+ return `~${path.sep}${relative}`;
133
+ }
134
+
135
+ function categoryForScope(scope: SkillScope): SkillRowCategory {
136
+ return scope === "global" ? "G" : "P";
137
+ }
138
+
139
+ function createExternalSkillId(name: string, filePath: string): string {
140
+ const safeName = (name || "skill")
141
+ .toLowerCase()
142
+ .replace(/[^a-z0-9-]+/g, "-")
143
+ .replace(/^-+|-+$/g, "") || "skill";
144
+ const hash = createHash("sha1").update(`${name}|${filePath}`).digest("hex").slice(0, 10);
145
+ return `external:${safeName}:${hash}`;
146
+ }
147
+
148
+ function matchesCategoryFilter(row: SkillModalRow, filters: SkillCategoryFilters): boolean {
149
+ if (row.category === "G") return filters.global;
150
+ if (row.category === "P") return filters.project;
151
+ return filters.external;
152
+ }
153
+
154
+ function categoryOrder(category: SkillRowCategory): number {
155
+ switch (category) {
156
+ case "G":
157
+ return 0;
158
+ case "P":
159
+ return 1;
160
+ case "E":
161
+ return 2;
162
+ }
163
+ }
164
+
165
+ export function collectLoadedSkillsFromCommands(commands: SkillCommandInfo[]): LoadedSkillRow[] {
166
+ const loaded: LoadedSkillRow[] = [];
167
+
168
+ for (const command of commands) {
169
+ if (!isRecord(command)) continue;
170
+ const source = getStringField(command.source);
171
+ if (source !== "skill") continue;
172
+
173
+ const commandName = getStringField(command.name)?.trim();
174
+ if (!commandName) continue;
175
+
176
+ const sourceInfo = isRecord(command.sourceInfo) ? command.sourceInfo : undefined;
177
+ const sourcePath = sourceInfo ? getStringField(sourceInfo.path)?.trim() : undefined;
178
+ if (!sourcePath) continue;
179
+
180
+ const rawName = commandName.startsWith("skill:")
181
+ ? commandName.slice("skill:".length)
182
+ : commandName;
183
+ const displayName = rawName || commandName;
184
+ const filePath = path.resolve(sourcePath);
185
+
186
+ loaded.push({
187
+ name: rawName || commandName,
188
+ displayName,
189
+ description: getStringField(command.description) || "",
190
+ path: filePath,
191
+ displayPath: formatSkillPath(filePath),
192
+ sourceScope: sourceInfo ? getStringField(sourceInfo.scope) : undefined,
193
+ sourceOrigin: sourceInfo ? getStringField(sourceInfo.origin) : undefined,
194
+ sourceLabel: sourceInfo ? getStringField(sourceInfo.source) : undefined,
195
+ });
196
+ }
197
+
198
+ return loaded.sort((a, b) => a.displayName.localeCompare(b.displayName));
199
+ }
200
+
201
+ export function formatSkillsList(rows: SkillModalRow[], projectName: string | null): string {
202
+ const globalSkills = rows.filter((row) => row.category === "G");
203
+ const projectSkills = rows.filter((row) => row.category === "P");
204
+ const externalSkills = rows.filter((row) => row.category === "E");
205
+
206
+ const lines: string[] = [];
207
+ lines.push("");
208
+ lines.push(" ╔═══════════════════════════════════════════════════════════╗");
209
+ lines.push(" ║ 🧠 Procedural Skills ║");
210
+ lines.push(" ╚═══════════════════════════════════════════════════════════╝");
211
+ lines.push(" Legend: [G] global · [P] project · [E] external (read-only)");
212
+ lines.push("");
213
+
214
+ if (rows.length === 0) {
215
+ lines.push(" (no skills found in this session)");
216
+ lines.push("");
217
+ lines.push(" Skills are auto-created after complex tasks,");
218
+ lines.push(" or you can ask the agent to create one.");
219
+ return lines.join("\n");
220
+ }
221
+
222
+ if (globalSkills.length > 0) {
223
+ lines.push(" [G] Global Skills");
224
+ lines.push(" ─────────────────");
225
+ for (const row of globalSkills) {
226
+ lines.push(` 📄 ${row.displayName} (${row.displayPath})`);
227
+ lines.push(` ${row.description || "(no description)"}`);
228
+ lines.push(` id: ${row.skillId}`);
18
229
  lines.push("");
19
- lines.push(" ╔══════════════════════════════════════════════╗");
20
- lines.push(" ║ 🧠 Procedural Skills ║");
21
- lines.push(" ╚══════════════════════════════════════════════╝");
230
+ }
231
+ }
232
+
233
+ if (projectSkills.length > 0) {
234
+ lines.push(` [P] Project Skills${projectName ? ` (${projectName})` : ""}`);
235
+ lines.push(" ─────────────────────────────────");
236
+ for (const row of projectSkills) {
237
+ lines.push(` 📄 ${row.displayName} (${row.displayPath})`);
238
+ lines.push(` ${row.description || "(no description)"}`);
239
+ lines.push(` id: ${row.skillId}`);
22
240
  lines.push("");
241
+ }
242
+ }
23
243
 
24
- if (skills.length === 0) {
25
- lines.push(" (no skills created yet)");
26
- lines.push("");
27
- lines.push(" Skills are auto-created after complex tasks,");
28
- lines.push(" or you can ask the agent to create one.");
29
- } else {
30
- if (globalSkills.length > 0) {
31
- lines.push(" Global Skills");
32
- lines.push(" ─────────────");
33
- for (const skill of globalSkills) {
34
- lines.push(` 📄 ${skill.displayName || skill.name}`);
35
- lines.push(` ${skill.description}`);
36
- lines.push(` id: ${skill.skillId}`);
37
- lines.push(` path: ${skill.path}`);
38
- lines.push("");
39
- }
244
+ if (externalSkills.length > 0) {
245
+ lines.push(" [E] External Skills (read-only)");
246
+ lines.push(" ───────────────────────────────");
247
+ for (const row of externalSkills) {
248
+ lines.push(` 📄 ${row.displayName} (${row.displayPath})`);
249
+ lines.push(` ${row.description || "(no description)"}`);
250
+ lines.push(` id: ${row.skillId}`);
251
+ lines.push("");
252
+ }
253
+ }
254
+
255
+ return lines.join("\n");
256
+ }
257
+
258
+ export function buildSkillRows(skills: SkillIndex[], selectedSkillIds = new Set<string>()): SkillModalRow[] {
259
+ return skills.map((skill) => {
260
+ const displayName = skill.displayName || skill.name;
261
+ const displayPath = formatSkillPath(skill.path);
262
+ return {
263
+ skillId: skill.skillId,
264
+ scope: skill.scope,
265
+ category: categoryForScope(skill.scope),
266
+ mutable: true,
267
+ name: skill.name,
268
+ displayName,
269
+ description: skill.description,
270
+ path: skill.path,
271
+ displayPath,
272
+ projectName: skill.projectName,
273
+ selected: selectedSkillIds.has(skill.skillId),
274
+ searchText: `${displayName} ${skill.name} ${skill.description || ""} ${skill.path} ${displayPath}`.trim(),
275
+ };
276
+ });
277
+ }
278
+
279
+ export function buildUnifiedSkillRows(
280
+ managedSkills: SkillIndex[],
281
+ loadedSkills: LoadedSkillRow[],
282
+ selectedSkillIds = new Set<string>(),
283
+ ): SkillModalRow[] {
284
+ const managedRows = buildSkillRows(managedSkills, selectedSkillIds);
285
+ const managedPathKeys = new Set(managedRows.map((row) => normalizePathForKey(row.path)));
286
+ const externalPathKeys = new Set<string>();
287
+
288
+ const externalRows: SkillModalRow[] = [];
289
+ for (const loaded of loadedSkills) {
290
+ const loadedKey = normalizePathForKey(loaded.path);
291
+ if (managedPathKeys.has(loadedKey)) continue;
292
+ if (externalPathKeys.has(loadedKey)) continue;
293
+ externalPathKeys.add(loadedKey);
294
+
295
+ const externalSkillId = createExternalSkillId(loaded.name, loaded.path);
296
+ externalRows.push({
297
+ skillId: externalSkillId,
298
+ scope: undefined,
299
+ category: "E",
300
+ mutable: false,
301
+ name: loaded.name,
302
+ displayName: loaded.displayName,
303
+ description: loaded.description,
304
+ path: loaded.path,
305
+ displayPath: loaded.displayPath,
306
+ selected: selectedSkillIds.has(externalSkillId),
307
+ searchText: `${loaded.displayName} ${loaded.name} ${loaded.description || ""} ${loaded.path} ${loaded.displayPath}`.trim(),
308
+ });
309
+ }
310
+
311
+ return [...managedRows, ...externalRows]
312
+ .sort((a, b) => {
313
+ const byCategory = categoryOrder(a.category) - categoryOrder(b.category);
314
+ if (byCategory !== 0) return byCategory;
315
+ return a.displayName.localeCompare(b.displayName);
316
+ });
317
+ }
318
+
319
+ export function filterSkillRows(rows: SkillModalRow[], query: string): SkillModalRow[] {
320
+ const trimmed = query.trim();
321
+ if (!trimmed) return rows;
322
+ return fuzzyFilter(rows, trimmed, (row) => row.searchText);
323
+ }
324
+
325
+ export function getSelectedSkillIds(rows: SkillModalRow[]): string[] {
326
+ return rows.filter((row) => row.selected).map((row) => row.skillId);
327
+ }
328
+
329
+ function summarizeAction(
330
+ actionVerb: string,
331
+ targetLabel: string,
332
+ successes: SkillResult[],
333
+ unchanged: SkillResult[],
334
+ blocked: Array<{ skillId: string; error: string }>,
335
+ ): string[] {
336
+ const lines: string[] = [];
337
+ const changed = successes.filter((result) => result.message?.includes(actionVerb) || result.skillId);
338
+
339
+ if (actionVerb === "moved") {
340
+ lines.push(`Moved ${successes.length} skill${successes.length === 1 ? "" : "s"} to ${targetLabel}.`);
341
+ } else if (actionVerb === "deleted") {
342
+ lines.push(`Deleted ${successes.length} skill${successes.length === 1 ? "" : "s"}.`);
343
+ } else {
344
+ lines.push(`${changed.length} skill action(s) completed.`);
345
+ }
346
+
347
+ if (unchanged.length > 0) {
348
+ lines.push(`${unchanged.length} already matched the target scope.`);
349
+ }
350
+
351
+ if (blocked.length > 0) {
352
+ lines.push(`Blocked ${blocked.length} skill${blocked.length === 1 ? "" : "s"}:`);
353
+ for (const item of blocked.slice(0, 4)) {
354
+ lines.push(`- ${item.skillId}: ${item.error}`);
355
+ }
356
+ if (blocked.length > 4) {
357
+ lines.push(`- …and ${blocked.length - 4} more`);
358
+ }
359
+ }
360
+
361
+ return lines;
362
+ }
363
+
364
+ type SkillMoveStore = Pick<SkillStore, "move" | "loadIndex" | "getProjectName">;
365
+ type SkillDeleteStore = Pick<SkillStore, "delete" | "loadIndex">;
366
+ export type ConfirmDialog = (title: string, message: string) => Promise<boolean>;
367
+
368
+ export interface SkillBatchActionResult {
369
+ skills: SkillIndex[];
370
+ summaryLines: string[];
371
+ retainSelectedSkillIds?: string[];
372
+ focusSkillId?: string;
373
+ }
374
+
375
+ export async function moveSelectedSkills(
376
+ store: SkillMoveStore,
377
+ skillIds: string[],
378
+ targetScope: SkillScope,
379
+ ): Promise<SkillBatchActionResult> {
380
+ const dedupedSkillIds = Array.from(new Set(skillIds));
381
+ const currentSkills = await store.loadIndex();
382
+
383
+ if (dedupedSkillIds.length === 0) {
384
+ return {
385
+ skills: currentSkills,
386
+ summaryLines: ["Select one or more skills first."],
387
+ };
388
+ }
389
+
390
+ if (targetScope === "project" && !store.getProjectName()) {
391
+ return {
392
+ skills: currentSkills,
393
+ summaryLines: ["Move to project is unavailable: no active project detected."],
394
+ retainSelectedSkillIds: dedupedSkillIds,
395
+ };
396
+ }
397
+
398
+ const successes: SkillResult[] = [];
399
+ const unchanged: SkillResult[] = [];
400
+ const blocked: Array<{ skillId: string; error: string }> = [];
401
+
402
+ for (const skillId of dedupedSkillIds) {
403
+ try {
404
+ const result = await store.move(skillId, targetScope);
405
+ if (result.success) {
406
+ if (result.skillId === skillId && result.scope === targetScope) {
407
+ unchanged.push(result);
408
+ } else {
409
+ successes.push(result);
40
410
  }
411
+ } else {
412
+ blocked.push({ skillId, error: result.error || "Unknown move failure." });
413
+ }
414
+ } catch (error) {
415
+ blocked.push({
416
+ skillId,
417
+ error: error instanceof Error ? error.message : String(error),
418
+ });
419
+ }
420
+ }
421
+
422
+ const refreshedSkills = await store.loadIndex();
423
+ const focusSkillId = blocked[0]?.skillId
424
+ ?? successes[0]?.skillId
425
+ ?? unchanged[0]?.skillId;
426
+
427
+ return {
428
+ skills: refreshedSkills,
429
+ summaryLines: summarizeAction("moved", targetScope, successes, unchanged, blocked),
430
+ retainSelectedSkillIds: blocked.map((item) => item.skillId),
431
+ focusSkillId,
432
+ };
433
+ }
434
+
435
+ export async function deleteSelectedSkills(
436
+ store: SkillDeleteStore,
437
+ skillIds: string[],
438
+ ): Promise<SkillBatchActionResult> {
439
+ const dedupedSkillIds = Array.from(new Set(skillIds));
440
+ const currentSkills = await store.loadIndex();
441
+
442
+ if (dedupedSkillIds.length === 0) {
443
+ return {
444
+ skills: currentSkills,
445
+ summaryLines: ["Select one or more skills first."],
446
+ };
447
+ }
448
+
449
+ const successes: SkillResult[] = [];
450
+ const blocked: Array<{ skillId: string; error: string }> = [];
451
+
452
+ for (const skillId of dedupedSkillIds) {
453
+ try {
454
+ const result = await store.delete(skillId);
455
+ if (result.success) {
456
+ successes.push(result);
457
+ } else {
458
+ blocked.push({ skillId, error: result.error || "Unknown delete failure." });
459
+ }
460
+ } catch (error) {
461
+ blocked.push({
462
+ skillId,
463
+ error: error instanceof Error ? error.message : String(error),
464
+ });
465
+ }
466
+ }
467
+
468
+ const refreshedSkills = await store.loadIndex();
469
+
470
+ return {
471
+ skills: refreshedSkills,
472
+ summaryLines: summarizeAction("deleted", "delete", successes, [], blocked),
473
+ retainSelectedSkillIds: blocked.map((item) => item.skillId),
474
+ focusSkillId: blocked[0]?.skillId,
475
+ };
476
+ }
477
+
478
+ export async function confirmDeleteSelectedSkills(
479
+ confirm: ConfirmDialog,
480
+ store: SkillDeleteStore,
481
+ skillIds: string[],
482
+ ): Promise<SkillBatchActionResult> {
483
+ const currentSkills = await store.loadIndex();
484
+ if (skillIds.length === 0) {
485
+ return { skills: currentSkills, summaryLines: ["Select one or more skills first."] };
486
+ }
487
+
488
+ const confirmed = await confirm(
489
+ "Delete selected skills?",
490
+ `Delete ${skillIds.length} selected skill${skillIds.length === 1 ? "" : "s"}? This cannot be undone.`,
491
+ );
492
+
493
+ if (!confirmed) {
494
+ return {
495
+ skills: currentSkills,
496
+ summaryLines: ["Delete cancelled."],
497
+ retainSelectedSkillIds: skillIds,
498
+ focusSkillId: skillIds[0],
499
+ };
500
+ }
501
+
502
+ return deleteSelectedSkills(store, skillIds);
503
+ }
504
+
505
+ interface SkillsManagerCallbacks {
506
+ moveSelected: (scope: SkillScope, skillIds: string[]) => Promise<SkillBatchActionResult>;
507
+ deleteSelected: (skillIds: string[]) => Promise<SkillBatchActionResult>;
508
+ close: () => void;
509
+ projectName: string | null;
510
+ }
511
+
512
+ export class SkillsManagerModal implements Focusable {
513
+ private _focused = false;
514
+
515
+ get focused(): boolean {
516
+ return this._focused;
517
+ }
518
+
519
+ set focused(value: boolean) {
520
+ this._focused = value;
521
+ this.syncSearchFocus();
522
+ }
523
+
524
+ private readonly searchInput = new Input();
525
+ private managedSkills: SkillIndex[];
526
+ private readonly loadedSkills: LoadedSkillRow[];
527
+ private rows: SkillModalRow[];
528
+ private selectedIndex = 0;
529
+ private query = "";
530
+ private focusArea: "search" | "list" | "filters" = "list";
531
+ private busy = false;
532
+ private closed = false;
533
+ private pendingDeleteConfirm: { skillIds: string[] } | null = null;
534
+ private activeFilters: SkillCategoryFilters = { ...DEFAULT_SKILL_FILTERS };
535
+ private pendingFilters: SkillCategoryFilters | null = null;
536
+ private filterCursor = 0;
537
+ private summaryLines: string[] = [
538
+ "Select skills with space, then move with g/p or delete with d. Press f for filters.",
539
+ ];
41
540
 
42
- if (projectSkills.length > 0) {
43
- lines.push(` Project Skills${projectName ? ` (${projectName})` : ""}`);
44
- lines.push(" ──────────────");
45
- for (const skill of projectSkills) {
46
- lines.push(` 📄 ${skill.displayName || skill.name}`);
47
- lines.push(` ${skill.description}`);
48
- lines.push(` id: ${skill.skillId}`);
49
- lines.push(` path: ${skill.path}`);
50
- lines.push("");
541
+ constructor(
542
+ private readonly tui: TUI,
543
+ private readonly theme: Theme,
544
+ initialRows: SkillModalRow[],
545
+ private readonly callbacks: SkillsManagerCallbacks,
546
+ options?: {
547
+ managedSkills?: SkillIndex[];
548
+ loadedSkills?: LoadedSkillRow[];
549
+ },
550
+ ) {
551
+ const selectedSkillIds = new Set(initialRows.filter((row) => row.selected).map((row) => row.skillId));
552
+
553
+ this.loadedSkills = options?.loadedSkills
554
+ ?? initialRows
555
+ .filter((row) => row.category === "E")
556
+ .map((row) => ({
557
+ name: row.name,
558
+ displayName: row.displayName,
559
+ description: row.description,
560
+ path: row.path,
561
+ displayPath: row.displayPath,
562
+ }));
563
+
564
+ this.managedSkills = options?.managedSkills
565
+ ?? initialRows
566
+ .filter((row) => row.category !== "E" && row.scope)
567
+ .map((row) => ({
568
+ skillId: row.skillId,
569
+ scope: row.scope!,
570
+ fileName: path.basename(row.path),
571
+ path: row.path,
572
+ projectName: row.projectName,
573
+ name: row.name,
574
+ displayName: row.displayName,
575
+ description: row.description,
576
+ }));
577
+
578
+ this.rows = buildUnifiedSkillRows(this.managedSkills, this.loadedSkills, selectedSkillIds);
579
+ this.syncSearchFocus();
580
+ }
581
+
582
+ invalidate(): void {
583
+ this.searchInput.invalidate();
584
+ }
585
+
586
+ private get filteredRows(): SkillModalRow[] {
587
+ const categoryFiltered = this.rows.filter((row) => matchesCategoryFilter(row, this.activeFilters));
588
+ return filterSkillRows(categoryFiltered, this.query);
589
+ }
590
+
591
+ private getCurrentRow(): SkillModalRow | null {
592
+ const rows = this.filteredRows;
593
+ if (rows.length === 0) return null;
594
+ return rows[Math.min(this.selectedIndex, rows.length - 1)] ?? null;
595
+ }
596
+
597
+ private getSelectedRows(): SkillModalRow[] {
598
+ return this.rows.filter((row) => row.selected);
599
+ }
600
+
601
+ private getSelectedIds(): string[] {
602
+ return getSelectedSkillIds(this.rows);
603
+ }
604
+
605
+ private getFilterOptions(): Array<{ key: keyof SkillCategoryFilters; label: string }> {
606
+ return [
607
+ { key: "global", label: "Global [G]" },
608
+ { key: "project", label: "Project [P]" },
609
+ { key: "external", label: "External [E] (read-only)" },
610
+ ];
611
+ }
612
+
613
+ private syncSearchFocus(): void {
614
+ this.searchInput.focused = this.focused && this.focusArea === "search";
615
+ }
616
+
617
+ private syncQueryFromInput(): void {
618
+ this.query = this.searchInput.getValue();
619
+ const rows = this.filteredRows;
620
+ if (rows.length === 0) {
621
+ this.selectedIndex = 0;
622
+ } else {
623
+ this.selectedIndex = Math.min(this.selectedIndex, rows.length - 1);
624
+ }
625
+ }
626
+
627
+ private setFocusArea(area: "search" | "list" | "filters"): void {
628
+ this.focusArea = area;
629
+ this.syncSearchFocus();
630
+ this.tui.requestRender();
631
+ }
632
+
633
+ private setRows(managedSkills: SkillIndex[], retainSelectedSkillIds: string[] = [], focusSkillId?: string): void {
634
+ this.managedSkills = managedSkills;
635
+ this.rows = buildUnifiedSkillRows(this.managedSkills, this.loadedSkills, new Set(retainSelectedSkillIds));
636
+ this.syncQueryFromInput();
637
+
638
+ const rows = this.filteredRows;
639
+ if (rows.length === 0) {
640
+ this.selectedIndex = 0;
641
+ return;
642
+ }
643
+
644
+ if (focusSkillId) {
645
+ const focusIndex = rows.findIndex((row) => row.skillId === focusSkillId);
646
+ if (focusIndex >= 0) {
647
+ this.selectedIndex = focusIndex;
648
+ return;
649
+ }
650
+ }
651
+
652
+ this.selectedIndex = Math.min(this.selectedIndex, rows.length - 1);
653
+ }
654
+
655
+ private toggleSelected(skillId: string): void {
656
+ const row = this.rows.find((entry) => entry.skillId === skillId);
657
+ if (!row) return;
658
+ row.selected = !row.selected;
659
+ }
660
+
661
+ private toggleCurrentSelection(): void {
662
+ const row = this.getCurrentRow();
663
+ if (!row) return;
664
+ this.toggleSelected(row.skillId);
665
+ this.summaryLines = [
666
+ `${row.selected ? "Selected" : "Cleared"} ${row.displayName}.`,
667
+ ];
668
+ this.tui.requestRender();
669
+ }
670
+
671
+ private selectAllFiltered(): void {
672
+ const rows = this.filteredRows;
673
+ for (const row of rows) {
674
+ row.selected = true;
675
+ }
676
+ this.summaryLines = [
677
+ `Selected ${rows.length} visible skill${rows.length === 1 ? "" : "s"}.`,
678
+ ];
679
+ this.tui.requestRender();
680
+ }
681
+
682
+ private clearSelection(): void {
683
+ for (const row of this.rows) {
684
+ row.selected = false;
685
+ }
686
+ this.summaryLines = ["Cleared all selections."];
687
+ this.tui.requestRender();
688
+ }
689
+
690
+ private appendExternalReadOnlySummary(
691
+ result: SkillBatchActionResult,
692
+ blockedExternalRows: SkillModalRow[],
693
+ verb: "move" | "delete",
694
+ ): SkillBatchActionResult {
695
+ if (blockedExternalRows.length === 0) return result;
696
+
697
+ const blockedIds = blockedExternalRows.map((row) => row.skillId);
698
+ const retainSet = new Set([...(result.retainSelectedSkillIds || []), ...blockedIds]);
699
+ const focusSkillId = result.focusSkillId || blockedIds[0];
700
+ const blockedLabel = blockedExternalRows.length === 1
701
+ ? `Blocked 1 external skill: ${blockedExternalRows[0]!.displayName} is read-only.`
702
+ : `Blocked ${blockedExternalRows.length} external skills: read-only (${verb} unavailable).`;
703
+
704
+ return {
705
+ ...result,
706
+ summaryLines: [...result.summaryLines, blockedLabel],
707
+ retainSelectedSkillIds: Array.from(retainSet),
708
+ focusSkillId,
709
+ };
710
+ }
711
+
712
+ private prepareMutableSelection(verb: "move" | "delete"):
713
+ | { proceed: false }
714
+ | { proceed: true; mutableIds: string[]; blockedExternalRows: SkillModalRow[] } {
715
+ const selectedRows = this.getSelectedRows();
716
+ if (selectedRows.length === 0) {
717
+ this.summaryLines = ["Select one or more skills first."];
718
+ this.tui.requestRender();
719
+ return { proceed: false };
720
+ }
721
+
722
+ const mutableRows = selectedRows.filter((row) => row.mutable);
723
+ const blockedExternalRows = selectedRows.filter((row) => !row.mutable);
724
+
725
+ if (mutableRows.length === 0 && blockedExternalRows.length > 0) {
726
+ this.summaryLines = [
727
+ `Blocked ${blockedExternalRows.length} external skill${blockedExternalRows.length === 1 ? "" : "s"}: read-only (${verb} unavailable).`,
728
+ ];
729
+ this.tui.requestRender();
730
+ return { proceed: false };
731
+ }
732
+
733
+ return {
734
+ proceed: true,
735
+ mutableIds: mutableRows.map((row) => row.skillId),
736
+ blockedExternalRows,
737
+ };
738
+ }
739
+
740
+ private async runMove(targetScope: SkillScope): Promise<void> {
741
+ const selection = this.prepareMutableSelection("move");
742
+ if (!selection.proceed) return;
743
+
744
+ const action = this.callbacks.moveSelected(targetScope, selection.mutableIds)
745
+ .then((result) => this.appendExternalReadOnlySummary(result, selection.blockedExternalRows, "move"));
746
+
747
+ await this.runAsyncAction(action);
748
+ }
749
+
750
+ private promptDelete(): void {
751
+ const selection = this.prepareMutableSelection("delete");
752
+ if (!selection.proceed) return;
753
+
754
+ this.pendingDeleteConfirm = { skillIds: selection.mutableIds };
755
+ const blockedCount = selection.blockedExternalRows.length;
756
+ this.summaryLines = [
757
+ `Delete ${selection.mutableIds.length} selected skill${selection.mutableIds.length === 1 ? "" : "s"}? Press y to confirm or n to cancel.${blockedCount > 0 ? ` (${blockedCount} external read-only item${blockedCount === 1 ? "" : "s"} will be skipped)` : ""}`,
758
+ ];
759
+ this.tui.requestRender();
760
+ }
761
+
762
+ private async runDeleteConfirmed(skillIds: string[]): Promise<void> {
763
+ const blockedExternalRows = this.rows.filter((row) => row.selected && !row.mutable);
764
+ const action = this.callbacks.deleteSelected(skillIds)
765
+ .then((result) => this.appendExternalReadOnlySummary(result, blockedExternalRows, "delete"));
766
+
767
+ await this.runAsyncAction(action);
768
+ }
769
+
770
+ private closeModal(): void {
771
+ if (this.closed) return;
772
+ this.closed = true;
773
+ this.callbacks.close();
774
+ }
775
+
776
+ private openFilterPanel(): void {
777
+ this.pendingFilters = cloneFilters(this.activeFilters);
778
+ this.filterCursor = 0;
779
+ this.setFocusArea("filters");
780
+ this.summaryLines = ["Filter panel open: space toggle · enter apply · esc cancel."];
781
+ this.tui.requestRender();
782
+ }
783
+
784
+ private applyFilterPanel(): void {
785
+ const candidate = ensureValidFilters(this.pendingFilters ? cloneFilters(this.pendingFilters) : cloneFilters(this.activeFilters));
786
+ const wasAllOff = this.pendingFilters
787
+ && !this.pendingFilters.global
788
+ && !this.pendingFilters.project
789
+ && !this.pendingFilters.external;
790
+
791
+ this.activeFilters = candidate;
792
+ this.pendingFilters = null;
793
+ this.syncQueryFromInput();
794
+ this.setFocusArea("list");
795
+ this.summaryLines = [
796
+ wasAllOff
797
+ ? "All categories were disabled; restored filters to [G] [P] [E]."
798
+ : `Applied filters: ${filtersLabel(this.activeFilters)}`,
799
+ ];
800
+ this.tui.requestRender();
801
+ }
802
+
803
+ private cancelFilterPanel(): void {
804
+ this.pendingFilters = null;
805
+ this.setFocusArea("list");
806
+ this.summaryLines = ["Filter changes cancelled."];
807
+ this.tui.requestRender();
808
+ }
809
+
810
+ private handleFilterInput(data: string): void {
811
+ const options = this.getFilterOptions();
812
+ const draft = this.pendingFilters ?? cloneFilters(this.activeFilters);
813
+ this.pendingFilters = draft;
814
+
815
+ if (matchesKey(data, Key.escape)) {
816
+ this.cancelFilterPanel();
817
+ return;
818
+ }
819
+ if (matchesKey(data, Key.up)) {
820
+ this.filterCursor = Math.max(0, this.filterCursor - 1);
821
+ this.tui.requestRender();
822
+ return;
823
+ }
824
+ if (matchesKey(data, Key.down)) {
825
+ this.filterCursor = Math.min(options.length - 1, this.filterCursor + 1);
826
+ this.tui.requestRender();
827
+ return;
828
+ }
829
+ if (matchesKey(data, Key.space)) {
830
+ const option = options[this.filterCursor];
831
+ if (option) {
832
+ draft[option.key] = !draft[option.key];
833
+ }
834
+ this.tui.requestRender();
835
+ return;
836
+ }
837
+ if (matchesKey(data, Key.enter)) {
838
+ this.applyFilterPanel();
839
+ }
840
+ }
841
+
842
+ private async runAsyncAction(action: Promise<SkillBatchActionResult>): Promise<void> {
843
+ if (this.closed) return;
844
+
845
+ this.busy = true;
846
+ this.summaryLines = ["Applying skill changes…"];
847
+ this.tui.requestRender();
848
+
849
+ try {
850
+ const result = await action;
851
+ if (this.closed) return;
852
+ this.setRows(result.skills, result.retainSelectedSkillIds, result.focusSkillId);
853
+ this.summaryLines = result.summaryLines;
854
+ } catch (error) {
855
+ if (!this.closed) {
856
+ this.summaryLines = [error instanceof Error ? error.message : String(error)];
857
+ }
858
+ } finally {
859
+ this.busy = false;
860
+ if (!this.closed) {
861
+ this.tui.requestRender();
862
+ }
863
+ }
864
+ }
865
+
866
+ private moveSelection(delta: number): void {
867
+ const rows = this.filteredRows;
868
+ if (rows.length === 0) return;
869
+ const next = this.selectedIndex + delta;
870
+ this.selectedIndex = Math.max(0, Math.min(next, rows.length - 1));
871
+ this.tui.requestRender();
872
+ }
873
+
874
+ private pageSelection(delta: number): void {
875
+ const pageSize = Math.max(5, this.getMaxVisibleRows() - 1);
876
+ this.moveSelection(delta * pageSize);
877
+ }
878
+
879
+ private getMaxVisibleRows(): number {
880
+ return Math.max(6, Math.min(14, this.tui.terminal.rows - 22));
881
+ }
882
+
883
+ private focusSearchWithOptionalInput(data?: string): void {
884
+ this.setFocusArea("search");
885
+ if (data) {
886
+ this.searchInput.handleInput(data);
887
+ this.syncQueryFromInput();
888
+ this.tui.requestRender();
889
+ }
890
+ }
891
+
892
+ private isPrintableInput(data: string): boolean {
893
+ return data.length === 1 && data >= " " && data !== "\x7f";
894
+ }
895
+
896
+ handleInput(data: string): void {
897
+ if (this.closed) return;
898
+
899
+ if (this.busy) {
900
+ if (matchesKey(data, Key.escape)) this.closeModal();
901
+ return;
902
+ }
903
+
904
+ if (this.pendingDeleteConfirm) {
905
+ if (data === "y" || data === "Y") {
906
+ const pending = this.pendingDeleteConfirm;
907
+ this.pendingDeleteConfirm = null;
908
+ void this.runDeleteConfirmed(pending.skillIds);
909
+ return;
910
+ }
911
+
912
+ if (data === "n" || data === "N" || matchesKey(data, Key.escape)) {
913
+ this.pendingDeleteConfirm = null;
914
+ this.summaryLines = ["Delete cancelled."];
915
+ this.tui.requestRender();
916
+ }
917
+ return;
918
+ }
919
+
920
+ if (this.focusArea === "filters") {
921
+ this.handleFilterInput(data);
922
+ return;
923
+ }
924
+
925
+ if (matchesKey(data, Key.escape)) {
926
+ this.closeModal();
927
+ return;
928
+ }
929
+
930
+ if (this.focusArea === "search") {
931
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.down)) {
932
+ this.setFocusArea("list");
933
+ return;
934
+ }
935
+
936
+ this.searchInput.handleInput(data);
937
+ this.syncQueryFromInput();
938
+ this.tui.requestRender();
939
+ return;
940
+ }
941
+
942
+ if (data === MEMORY_SKILLS_KEYMAP.openFilters) {
943
+ this.openFilterPanel();
944
+ return;
945
+ }
946
+
947
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.slash)) {
948
+ this.focusSearchWithOptionalInput();
949
+ return;
950
+ }
951
+ if (matchesKey(data, Key.up)) {
952
+ this.moveSelection(-1);
953
+ return;
954
+ }
955
+ if (matchesKey(data, Key.down)) {
956
+ this.moveSelection(1);
957
+ return;
958
+ }
959
+ if (matchesKey(data, Key.pageUp)) {
960
+ this.pageSelection(-1);
961
+ return;
962
+ }
963
+ if (matchesKey(data, Key.pageDown)) {
964
+ this.pageSelection(1);
965
+ return;
966
+ }
967
+ if (matchesKey(data, Key.home)) {
968
+ this.selectedIndex = 0;
969
+ this.tui.requestRender();
970
+ return;
971
+ }
972
+ if (matchesKey(data, Key.end)) {
973
+ this.selectedIndex = Math.max(0, this.filteredRows.length - 1);
974
+ this.tui.requestRender();
975
+ return;
976
+ }
977
+ if (matchesKey(data, Key.space)) {
978
+ this.toggleCurrentSelection();
979
+ return;
980
+ }
981
+ if (data === MEMORY_SKILLS_KEYMAP.selectAllFiltered) {
982
+ this.selectAllFiltered();
983
+ return;
984
+ }
985
+ if (data === MEMORY_SKILLS_KEYMAP.clearSelection) {
986
+ this.clearSelection();
987
+ return;
988
+ }
989
+ if (data === MEMORY_SKILLS_KEYMAP.moveGlobal) {
990
+ void this.runMove("global");
991
+ return;
992
+ }
993
+ if (data === MEMORY_SKILLS_KEYMAP.moveProject) {
994
+ void this.runMove("project");
995
+ return;
996
+ }
997
+ if (data === MEMORY_SKILLS_KEYMAP.deleteSelected) {
998
+ this.promptDelete();
999
+ return;
1000
+ }
1001
+ if (this.isPrintableInput(data) && !["g", "p", "d", "a", "n", "f"].includes(data)) {
1002
+ this.focusSearchWithOptionalInput(data);
1003
+ }
1004
+ }
1005
+
1006
+ private renderFramedLine(content: string, width: number): string {
1007
+ const innerWidth = Math.max(10, width - 4);
1008
+ const padded = truncateToWidth(content, innerWidth, "");
1009
+ const spaces = Math.max(0, innerWidth - visibleWidth(padded));
1010
+ return `${this.theme.fg("borderAccent", "│")} ${padded}${" ".repeat(spaces)} ${this.theme.fg("borderAccent", "│")}`;
1011
+ }
1012
+
1013
+ private renderWrappedSection(lines: string[], width: number): string[] {
1014
+ const rendered: string[] = [];
1015
+ const innerWidth = Math.max(10, width - 4);
1016
+ for (const line of lines) {
1017
+ const wrapped = wrapTextWithAnsi(line, innerWidth);
1018
+ if (wrapped.length === 0) {
1019
+ rendered.push(this.renderFramedLine("", width));
1020
+ continue;
1021
+ }
1022
+ for (const part of wrapped) {
1023
+ rendered.push(this.renderFramedLine(part, width));
1024
+ }
1025
+ }
1026
+ return rendered;
1027
+ }
1028
+
1029
+ private renderFilterPanel(width: number): string[] {
1030
+ const panelWidth = Math.max(34, Math.min(width - 10, 58));
1031
+ const top = this.theme.fg("borderAccent", `┌${"─".repeat(Math.max(1, panelWidth - 2))}┐`);
1032
+ const bottom = this.theme.fg("borderAccent", `└${"─".repeat(Math.max(1, panelWidth - 2))}┘`);
1033
+ const lines: string[] = [top];
1034
+
1035
+ lines.push(this.renderFramedLine(this.theme.fg("accent", this.theme.bold("Filters")), panelWidth));
1036
+ lines.push(this.renderFramedLine(this.theme.fg("dim", "Space toggle · Enter apply · Esc cancel"), panelWidth));
1037
+ lines.push(this.renderFramedLine("", panelWidth));
1038
+
1039
+ const draft = this.pendingFilters ?? this.activeFilters;
1040
+ const options = this.getFilterOptions();
1041
+ for (let i = 0; i < options.length; i++) {
1042
+ const option = options[i]!;
1043
+ const checked = draft[option.key] ? "[x]" : "[ ]";
1044
+ const cursor = i === this.filterCursor ? this.theme.fg("accent", "›") : " ";
1045
+ const text = `${cursor} ${checked} ${option.label}`;
1046
+ const rendered = i === this.filterCursor
1047
+ ? this.theme.bg("selectedBg", truncateToWidth(text, Math.max(10, panelWidth - 4), ""))
1048
+ : truncateToWidth(text, Math.max(10, panelWidth - 4), "");
1049
+ lines.push(this.renderFramedLine(rendered, panelWidth));
1050
+ }
1051
+
1052
+ lines.push(this.renderFramedLine("", panelWidth));
1053
+ lines.push(this.renderFramedLine(this.theme.fg("dim", `Draft: ${filtersLabel(draft)}`), panelWidth));
1054
+ lines.push(bottom);
1055
+ return lines;
1056
+ }
1057
+
1058
+ render(width: number): string[] {
1059
+ const safeWidth = Math.max(60, width);
1060
+ const top = this.theme.fg("borderAccent", `┌${"─".repeat(Math.max(1, safeWidth - 2))}┐`);
1061
+ const bottom = this.theme.fg("borderAccent", `└${"─".repeat(Math.max(1, safeWidth - 2))}┘`);
1062
+ const lines: string[] = [top];
1063
+
1064
+ const projectName = this.callbacks.projectName ? ` · project: ${this.callbacks.projectName}` : "";
1065
+ const title = this.theme.fg("accent", this.theme.bold(`🧠 Procedural Skills${projectName}`));
1066
+ lines.push(this.renderFramedLine(title, safeWidth));
1067
+
1068
+ const searchHint = this.focusArea === "search"
1069
+ ? this.theme.fg("accent", "search")
1070
+ : this.theme.fg("dim", "search");
1071
+ const searchLine = this.searchInput.render(Math.max(10, safeWidth - 17))[0] ?? "";
1072
+ lines.push(this.renderFramedLine(`${searchHint}: ${searchLine}`, safeWidth));
1073
+
1074
+ const filteredRows = this.filteredRows;
1075
+ const selectedCount = this.getSelectedIds().length;
1076
+ lines.push(this.renderFramedLine(
1077
+ this.theme.fg(
1078
+ "dim",
1079
+ `${filteredRows.length} visible · ${this.rows.length} total · ${selectedCount} selected${this.busy ? " · working…" : ""}`,
1080
+ ),
1081
+ safeWidth,
1082
+ ));
1083
+
1084
+ lines.push(this.renderFramedLine(this.theme.fg("dim", `Legend: [G] global · [P] project · [E] external (read-only) · filters: ${filtersLabel(this.activeFilters)}`), safeWidth));
1085
+ lines.push(this.renderFramedLine("", safeWidth));
1086
+
1087
+ if (filteredRows.length === 0) {
1088
+ const emptyMessage = this.rows.length === 0 ? "No skills found yet." : "No skills match the current filters/search.";
1089
+ lines.push(this.renderFramedLine(this.theme.fg("warning", emptyMessage), safeWidth));
1090
+ lines.push(this.renderFramedLine("", safeWidth));
1091
+ } else {
1092
+ const maxVisible = this.getMaxVisibleRows();
1093
+ const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), filteredRows.length - maxVisible));
1094
+ const end = Math.min(filteredRows.length, start + maxVisible);
1095
+ const visibleRows = filteredRows.slice(start, end);
1096
+
1097
+ for (let i = 0; i < visibleRows.length; i++) {
1098
+ const row = visibleRows[i]!;
1099
+ const absoluteIndex = start + i;
1100
+ const cursor = absoluteIndex === this.selectedIndex ? this.theme.fg("accent", "›") : " ";
1101
+ const check = row.selected ? this.theme.fg("accent", "[x]") : this.theme.fg("dim", "[ ]");
1102
+ const category = row.category === "G"
1103
+ ? this.theme.fg("accent", "[G]")
1104
+ : row.category === "P"
1105
+ ? this.theme.fg("warning", "[P]")
1106
+ : this.theme.fg("dim", "[E]");
1107
+
1108
+ const baseText = `${cursor} ${check} ${category} ${row.displayName} (${row.displayPath})`;
1109
+ const lineText = absoluteIndex === this.selectedIndex
1110
+ ? this.theme.bg("selectedBg", truncateToWidth(baseText, Math.max(10, safeWidth - 4), ""))
1111
+ : truncateToWidth(baseText, Math.max(10, safeWidth - 4), "");
1112
+ lines.push(this.renderFramedLine(lineText, safeWidth));
1113
+ }
1114
+
1115
+ if (start > 0 || end < filteredRows.length) {
1116
+ lines.push(this.renderFramedLine(this.theme.fg("dim", `Showing ${start + 1}-${end} of ${filteredRows.length}`), safeWidth));
1117
+ }
1118
+
1119
+ lines.push(this.renderFramedLine("", safeWidth));
1120
+ const currentRow = this.getCurrentRow();
1121
+ if (currentRow) {
1122
+ const scopeLabel = currentRow.category === "E"
1123
+ ? "external (read-only)"
1124
+ : currentRow.scope === "project"
1125
+ ? "project"
1126
+ : "global";
1127
+ lines.push(this.renderFramedLine(this.theme.fg("accent", `Focused: ${currentRow.displayName} · ${scopeLabel}`), safeWidth));
1128
+ lines.push(...this.renderWrappedSection([
1129
+ currentRow.description || "(no description)",
1130
+ this.theme.fg("dim", currentRow.skillId),
1131
+ this.theme.fg("dim", currentRow.displayPath),
1132
+ ], safeWidth));
1133
+ }
1134
+ }
1135
+
1136
+ lines.push(this.renderFramedLine("", safeWidth));
1137
+ lines.push(this.renderFramedLine(this.theme.fg("accent", "Last action"), safeWidth));
1138
+ lines.push(...this.renderWrappedSection(this.summaryLines, safeWidth));
1139
+ lines.push(this.renderFramedLine("", safeWidth));
1140
+
1141
+ const help = this.pendingDeleteConfirm
1142
+ ? "Confirm delete: y yes · n no · esc cancel"
1143
+ : this.callbacks.projectName
1144
+ ? "↑↓ move · space select · / search · f filters · tab switch · g global · p project · d delete · a all · n none · esc close"
1145
+ : "↑↓ move · space select · / search · f filters · tab switch · g global · p project (disabled) · d delete · a all · n none · esc close";
1146
+ lines.push(this.renderFramedLine(this.theme.fg("dim", help), safeWidth));
1147
+
1148
+ if (this.focusArea === "filters") {
1149
+ lines.push(this.renderFramedLine("", safeWidth));
1150
+ for (const panelLine of this.renderFilterPanel(Math.min(64, safeWidth - 6))) {
1151
+ lines.push(this.renderFramedLine(panelLine, safeWidth));
1152
+ }
1153
+ }
1154
+
1155
+ lines.push(bottom);
1156
+ return lines;
1157
+ }
1158
+ }
1159
+
1160
+ export function registerSkillsCommand(pi: ExtensionAPI, store: SkillStore): void {
1161
+ pi.registerCommand("memory-skills", {
1162
+ description: "Manage global, active-project, and loaded external procedural skills",
1163
+ handler: async (_args, ctx: ExtensionCommandContext) => {
1164
+ const getSkillCommands = (): SkillCommandInfo[] => {
1165
+ const readCommands = (owner: unknown): SkillCommandInfo[] | null => {
1166
+ try {
1167
+ const getter = (owner as { getCommands?: () => unknown })?.getCommands;
1168
+ if (typeof getter !== "function") return null;
1169
+ const commands = getter.call(owner);
1170
+ return Array.isArray(commands) ? commands as SkillCommandInfo[] : [];
1171
+ } catch {
1172
+ return null;
51
1173
  }
52
- }
1174
+ };
1175
+
1176
+ return readCommands(pi)
1177
+ ?? readCommands(ctx)
1178
+ ?? [];
1179
+ };
1180
+
1181
+ const managedSkills = await store.loadIndex();
1182
+ const loadedSkills = collectLoadedSkillsFromCommands(getSkillCommands());
1183
+ const initialRows = buildUnifiedSkillRows(managedSkills, loadedSkills);
1184
+ const projectName = store.getProjectName();
1185
+
1186
+ if (!ctx.hasUI || typeof ctx.ui.custom !== "function") {
1187
+ ctx.ui.notify(formatSkillsList(initialRows, projectName), "info");
1188
+ return;
53
1189
  }
54
1190
 
55
- ctx.ui.notify(lines.join("\n"), "info");
1191
+ try {
1192
+ await ctx.ui.custom<void>(
1193
+ (tui, theme, _keybindings, done) => new SkillsManagerModal(
1194
+ tui,
1195
+ theme,
1196
+ initialRows,
1197
+ {
1198
+ moveSelected: (scope, skillIds) => moveSelectedSkills(store, skillIds, scope),
1199
+ deleteSelected: (skillIds) => deleteSelectedSkills(store, skillIds),
1200
+ close: () => done(undefined),
1201
+ projectName,
1202
+ },
1203
+ {
1204
+ managedSkills,
1205
+ loadedSkills,
1206
+ },
1207
+ ),
1208
+ {
1209
+ overlay: true,
1210
+ overlayOptions: {
1211
+ anchor: "center",
1212
+ width: "92%",
1213
+ minWidth: 76,
1214
+ maxHeight: "88%",
1215
+ margin: 1,
1216
+ },
1217
+ },
1218
+ );
1219
+ } catch {
1220
+ const latestManagedSkills = await store.loadIndex();
1221
+ const latestRows = buildUnifiedSkillRows(
1222
+ latestManagedSkills,
1223
+ collectLoadedSkillsFromCommands(getSkillCommands()),
1224
+ );
1225
+ ctx.ui.notify(
1226
+ "Interactive skills manager unavailable in this runtime; showing read-only list fallback.",
1227
+ "warning",
1228
+ );
1229
+ ctx.ui.notify(formatSkillsList(latestRows, projectName), "info");
1230
+ }
56
1231
  },
57
1232
  });
58
1233
  }