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.
- package/README.md +52 -38
- package/package.json +2 -1
- package/src/config.ts +14 -1
- package/src/extension-root-migration.ts +101 -0
- package/src/handlers/index-sessions.ts +1 -1
- package/src/handlers/skills-command.ts +1216 -41
- package/src/index.ts +43 -29
- package/src/project-memory-migration.ts +1 -1
- package/src/store/memory-store.ts +1 -1
- package/src/store/session-anchor-search.ts +472 -0
- package/src/store/skill-store.ts +279 -43
- package/src/tools/session-search-tool.ts +106 -1
- package/src/types.ts +11 -6
|
@@ -1,58 +1,1233 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Skills command — /memory-skills
|
|
2
|
+
* Skills command — /memory-skills opens an interactive skills manager.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
}
|