pi-hermes-memory 0.7.6 → 0.7.8
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 +80 -33
- package/docs/PUBLISHING.md +1 -1
- package/docs/ROADMAP.md +1 -1
- package/package.json +5 -4
- package/src/constants.ts +7 -3
- package/src/handlers/auto-consolidate.ts +1 -1
- package/src/handlers/background-review.ts +1 -1
- package/src/handlers/correction-detector.ts +1 -1
- package/src/handlers/index-sessions.ts +1 -1
- package/src/handlers/insights.ts +1 -1
- package/src/handlers/interview.ts +1 -1
- package/src/handlers/learn-memory.ts +4 -4
- package/src/handlers/preview-context.ts +5 -15
- package/src/handlers/session-flush.ts +1 -1
- package/src/handlers/skill-auto-trigger.ts +24 -4
- package/src/handlers/skills-command.ts +749 -23
- package/src/handlers/switch-project.ts +1 -1
- package/src/handlers/sync-markdown-memories.ts +1 -1
- package/src/index.ts +58 -27
- package/src/project.ts +13 -0
- package/src/prompt-context.ts +0 -4
- package/src/skills/procedural-skill-creator/SKILL.md +146 -0
- package/src/store/skill-store.ts +600 -181
- package/src/store/skill-utils.ts +116 -0
- package/src/tools/memory-search-tool.ts +2 -2
- package/src/tools/memory-tool.ts +2 -2
- package/src/tools/session-search-tool.ts +2 -2
- package/src/tools/skill-tool.ts +23 -20
- package/src/types.ts +22 -4
|
@@ -1,38 +1,764 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Skills command — /memory-skills
|
|
2
|
+
* Skills command — /memory-skills opens an interactive skills manager.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { ExtensionAPI } from "@
|
|
5
|
+
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { SkillStore } from "../store/skill-store.js";
|
|
7
|
+
import type { SkillIndex, SkillResult, SkillScope } from "../types.js";
|
|
8
|
+
import {
|
|
9
|
+
Input,
|
|
10
|
+
Key,
|
|
11
|
+
fuzzyFilter,
|
|
12
|
+
matchesKey,
|
|
13
|
+
truncateToWidth,
|
|
14
|
+
visibleWidth,
|
|
15
|
+
wrapTextWithAnsi,
|
|
16
|
+
type Focusable,
|
|
17
|
+
type TUI,
|
|
18
|
+
} from "@earendil-works/pi-tui";
|
|
7
19
|
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
20
|
+
export const MEMORY_SKILLS_KEYMAP = {
|
|
21
|
+
moveGlobal: "g",
|
|
22
|
+
moveProject: "p",
|
|
23
|
+
deleteSelected: "d",
|
|
24
|
+
selectAllFiltered: "a",
|
|
25
|
+
clearSelection: "n",
|
|
26
|
+
focusSearch: "/",
|
|
27
|
+
toggleSelection: "space",
|
|
28
|
+
switchFocus: "tab",
|
|
29
|
+
close: "esc",
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export interface SkillModalRow {
|
|
33
|
+
skillId: string;
|
|
34
|
+
scope: SkillScope;
|
|
35
|
+
name: string;
|
|
36
|
+
displayName: string;
|
|
37
|
+
description: string;
|
|
38
|
+
path: string;
|
|
39
|
+
projectName?: string;
|
|
40
|
+
selected: boolean;
|
|
41
|
+
searchText: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SkillBatchActionResult {
|
|
45
|
+
skills: SkillIndex[];
|
|
46
|
+
summaryLines: string[];
|
|
47
|
+
retainSelectedSkillIds?: string[];
|
|
48
|
+
focusSkillId?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatSkillsList(skills: SkillIndex[], projectName: string | null): string {
|
|
52
|
+
const globalSkills = skills.filter((skill) => skill.scope === "global");
|
|
53
|
+
const projectSkills = skills.filter((skill) => skill.scope === "project");
|
|
13
54
|
|
|
14
|
-
|
|
55
|
+
const lines: string[] = [];
|
|
56
|
+
lines.push("");
|
|
57
|
+
lines.push(" ╔══════════════════════════════════════════════╗");
|
|
58
|
+
lines.push(" ║ 🧠 Procedural Skills ║");
|
|
59
|
+
lines.push(" ╚══════════════════════════════════════════════╝");
|
|
60
|
+
lines.push("");
|
|
61
|
+
|
|
62
|
+
if (skills.length === 0) {
|
|
63
|
+
lines.push(" (no skills created yet)");
|
|
64
|
+
lines.push("");
|
|
65
|
+
lines.push(" Skills are auto-created after complex tasks,");
|
|
66
|
+
lines.push(" or you can ask the agent to create one.");
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (globalSkills.length > 0) {
|
|
71
|
+
lines.push(" Global Skills");
|
|
72
|
+
lines.push(" ─────────────");
|
|
73
|
+
for (const skill of globalSkills) {
|
|
74
|
+
lines.push(` 📄 ${skill.displayName || skill.name}`);
|
|
75
|
+
lines.push(` ${skill.description}`);
|
|
76
|
+
lines.push(` id: ${skill.skillId}`);
|
|
77
|
+
lines.push(` path: ${skill.path}`);
|
|
15
78
|
lines.push("");
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (projectSkills.length > 0) {
|
|
83
|
+
lines.push(` Project Skills${projectName ? ` (${projectName})` : ""}`);
|
|
84
|
+
lines.push(" ──────────────");
|
|
85
|
+
for (const skill of projectSkills) {
|
|
86
|
+
lines.push(` 📄 ${skill.displayName || skill.name}`);
|
|
87
|
+
lines.push(` ${skill.description}`);
|
|
88
|
+
lines.push(` id: ${skill.skillId}`);
|
|
89
|
+
lines.push(` path: ${skill.path}`);
|
|
19
90
|
lines.push("");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
20
93
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function buildSkillRows(skills: SkillIndex[], selectedSkillIds = new Set<string>()): SkillModalRow[] {
|
|
98
|
+
return skills.map((skill) => ({
|
|
99
|
+
skillId: skill.skillId,
|
|
100
|
+
scope: skill.scope,
|
|
101
|
+
name: skill.name,
|
|
102
|
+
displayName: skill.displayName || skill.name,
|
|
103
|
+
description: skill.description,
|
|
104
|
+
path: skill.path,
|
|
105
|
+
projectName: skill.projectName,
|
|
106
|
+
selected: selectedSkillIds.has(skill.skillId),
|
|
107
|
+
searchText: `${skill.displayName || skill.name} ${skill.name}`.trim(),
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function filterSkillRows(rows: SkillModalRow[], query: string): SkillModalRow[] {
|
|
112
|
+
const trimmed = query.trim();
|
|
113
|
+
if (!trimmed) return rows;
|
|
114
|
+
return fuzzyFilter(rows, trimmed, (row) => row.searchText);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getSelectedSkillIds(rows: SkillModalRow[]): string[] {
|
|
118
|
+
return rows.filter((row) => row.selected).map((row) => row.skillId);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function summarizeAction(
|
|
122
|
+
actionVerb: string,
|
|
123
|
+
targetLabel: string,
|
|
124
|
+
successes: SkillResult[],
|
|
125
|
+
unchanged: SkillResult[],
|
|
126
|
+
blocked: Array<{ skillId: string; error: string }>,
|
|
127
|
+
): string[] {
|
|
128
|
+
const lines: string[] = [];
|
|
129
|
+
const changed = successes.filter((result) => result.message?.includes(actionVerb) || result.skillId);
|
|
130
|
+
|
|
131
|
+
if (actionVerb === "moved") {
|
|
132
|
+
lines.push(`Moved ${successes.length} skill${successes.length === 1 ? "" : "s"} to ${targetLabel}.`);
|
|
133
|
+
} else if (actionVerb === "deleted") {
|
|
134
|
+
lines.push(`Deleted ${successes.length} skill${successes.length === 1 ? "" : "s"}.`);
|
|
135
|
+
} else {
|
|
136
|
+
lines.push(`${changed.length} skill action(s) completed.`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (unchanged.length > 0) {
|
|
140
|
+
lines.push(`${unchanged.length} already matched the target scope.`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (blocked.length > 0) {
|
|
144
|
+
lines.push(`Blocked ${blocked.length} skill${blocked.length === 1 ? "" : "s"}:`);
|
|
145
|
+
for (const item of blocked.slice(0, 4)) {
|
|
146
|
+
lines.push(`- ${item.skillId}: ${item.error}`);
|
|
147
|
+
}
|
|
148
|
+
if (blocked.length > 4) {
|
|
149
|
+
lines.push(`- …and ${blocked.length - 4} more`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return lines;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type SkillMoveStore = Pick<SkillStore, "move" | "loadIndex" | "getProjectName">;
|
|
157
|
+
type SkillDeleteStore = Pick<SkillStore, "delete" | "loadIndex">;
|
|
158
|
+
export type ConfirmDialog = (title: string, message: string) => Promise<boolean>;
|
|
159
|
+
|
|
160
|
+
export async function moveSelectedSkills(
|
|
161
|
+
store: SkillMoveStore,
|
|
162
|
+
skillIds: string[],
|
|
163
|
+
targetScope: SkillScope,
|
|
164
|
+
): Promise<SkillBatchActionResult> {
|
|
165
|
+
const dedupedSkillIds = Array.from(new Set(skillIds));
|
|
166
|
+
const currentSkills = await store.loadIndex();
|
|
167
|
+
|
|
168
|
+
if (dedupedSkillIds.length === 0) {
|
|
169
|
+
return {
|
|
170
|
+
skills: currentSkills,
|
|
171
|
+
summaryLines: ["Select one or more skills first."],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (targetScope === "project" && !store.getProjectName()) {
|
|
176
|
+
return {
|
|
177
|
+
skills: currentSkills,
|
|
178
|
+
summaryLines: ["Move to project is unavailable: no active project detected."],
|
|
179
|
+
retainSelectedSkillIds: dedupedSkillIds,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const successes: SkillResult[] = [];
|
|
184
|
+
const unchanged: SkillResult[] = [];
|
|
185
|
+
const blocked: Array<{ skillId: string; error: string }> = [];
|
|
186
|
+
|
|
187
|
+
for (const skillId of dedupedSkillIds) {
|
|
188
|
+
try {
|
|
189
|
+
const result = await store.move(skillId, targetScope);
|
|
190
|
+
if (result.success) {
|
|
191
|
+
if (result.skillId === skillId && result.scope === targetScope) {
|
|
192
|
+
unchanged.push(result);
|
|
193
|
+
} else {
|
|
194
|
+
successes.push(result);
|
|
32
195
|
}
|
|
196
|
+
} else {
|
|
197
|
+
blocked.push({ skillId, error: result.error || "Unknown move failure." });
|
|
33
198
|
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
blocked.push({
|
|
201
|
+
skillId,
|
|
202
|
+
error: error instanceof Error ? error.message : String(error),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const refreshedSkills = await store.loadIndex();
|
|
208
|
+
const focusSkillId = blocked[0]?.skillId
|
|
209
|
+
?? successes[0]?.skillId
|
|
210
|
+
?? unchanged[0]?.skillId;
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
skills: refreshedSkills,
|
|
214
|
+
summaryLines: summarizeAction("moved", targetScope, successes, unchanged, blocked),
|
|
215
|
+
retainSelectedSkillIds: blocked.map((item) => item.skillId),
|
|
216
|
+
focusSkillId,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function deleteSelectedSkills(
|
|
221
|
+
store: SkillDeleteStore,
|
|
222
|
+
skillIds: string[],
|
|
223
|
+
): Promise<SkillBatchActionResult> {
|
|
224
|
+
const dedupedSkillIds = Array.from(new Set(skillIds));
|
|
225
|
+
const currentSkills = await store.loadIndex();
|
|
226
|
+
|
|
227
|
+
if (dedupedSkillIds.length === 0) {
|
|
228
|
+
return {
|
|
229
|
+
skills: currentSkills,
|
|
230
|
+
summaryLines: ["Select one or more skills first."],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const successes: SkillResult[] = [];
|
|
235
|
+
const blocked: Array<{ skillId: string; error: string }> = [];
|
|
236
|
+
|
|
237
|
+
for (const skillId of dedupedSkillIds) {
|
|
238
|
+
try {
|
|
239
|
+
const result = await store.delete(skillId);
|
|
240
|
+
if (result.success) {
|
|
241
|
+
successes.push(result);
|
|
242
|
+
} else {
|
|
243
|
+
blocked.push({ skillId, error: result.error || "Unknown delete failure." });
|
|
244
|
+
}
|
|
245
|
+
} catch (error) {
|
|
246
|
+
blocked.push({
|
|
247
|
+
skillId,
|
|
248
|
+
error: error instanceof Error ? error.message : String(error),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const refreshedSkills = await store.loadIndex();
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
skills: refreshedSkills,
|
|
257
|
+
summaryLines: summarizeAction("deleted", "delete", successes, [], blocked),
|
|
258
|
+
retainSelectedSkillIds: blocked.map((item) => item.skillId),
|
|
259
|
+
focusSkillId: blocked[0]?.skillId,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function confirmDeleteSelectedSkills(
|
|
264
|
+
confirm: ConfirmDialog,
|
|
265
|
+
store: SkillDeleteStore,
|
|
266
|
+
skillIds: string[],
|
|
267
|
+
): Promise<SkillBatchActionResult> {
|
|
268
|
+
const currentSkills = await store.loadIndex();
|
|
269
|
+
if (skillIds.length === 0) {
|
|
270
|
+
return { skills: currentSkills, summaryLines: ["Select one or more skills first."] };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const confirmed = await confirm(
|
|
274
|
+
"Delete selected skills?",
|
|
275
|
+
`Delete ${skillIds.length} selected skill${skillIds.length === 1 ? "" : "s"}? This cannot be undone.`,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
if (!confirmed) {
|
|
279
|
+
return {
|
|
280
|
+
skills: currentSkills,
|
|
281
|
+
summaryLines: ["Delete cancelled."],
|
|
282
|
+
retainSelectedSkillIds: skillIds,
|
|
283
|
+
focusSkillId: skillIds[0],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return deleteSelectedSkills(store, skillIds);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
interface SkillsManagerCallbacks {
|
|
291
|
+
moveSelected: (scope: SkillScope, skillIds: string[]) => Promise<SkillBatchActionResult>;
|
|
292
|
+
deleteSelected: (skillIds: string[]) => Promise<SkillBatchActionResult>;
|
|
293
|
+
close: () => void;
|
|
294
|
+
projectName: string | null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export class SkillsManagerModal implements Focusable {
|
|
298
|
+
private _focused = false;
|
|
299
|
+
|
|
300
|
+
get focused(): boolean {
|
|
301
|
+
return this._focused;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
set focused(value: boolean) {
|
|
305
|
+
this._focused = value;
|
|
306
|
+
this.syncSearchFocus();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private readonly searchInput = new Input();
|
|
310
|
+
private rows: SkillModalRow[];
|
|
311
|
+
private selectedIndex = 0;
|
|
312
|
+
private query = "";
|
|
313
|
+
private focusArea: "search" | "list" = "list";
|
|
314
|
+
private busy = false;
|
|
315
|
+
private closed = false;
|
|
316
|
+
private pendingDeleteConfirm: { skillIds: string[] } | null = null;
|
|
317
|
+
private summaryLines: string[] = [
|
|
318
|
+
"Select skills with space, then move with g/p or delete with d.",
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
constructor(
|
|
322
|
+
private readonly tui: TUI,
|
|
323
|
+
private readonly theme: Theme,
|
|
324
|
+
initialRows: SkillModalRow[],
|
|
325
|
+
private readonly callbacks: SkillsManagerCallbacks,
|
|
326
|
+
) {
|
|
327
|
+
this.rows = initialRows;
|
|
328
|
+
this.syncSearchFocus();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
invalidate(): void {
|
|
332
|
+
this.searchInput.invalidate();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private get filteredRows(): SkillModalRow[] {
|
|
336
|
+
return filterSkillRows(this.rows, this.query);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private getCurrentRow(): SkillModalRow | null {
|
|
340
|
+
const rows = this.filteredRows;
|
|
341
|
+
if (rows.length === 0) return null;
|
|
342
|
+
return rows[Math.min(this.selectedIndex, rows.length - 1)] ?? null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private getSelectedIds(): string[] {
|
|
346
|
+
return getSelectedSkillIds(this.rows);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private syncSearchFocus(): void {
|
|
350
|
+
this.searchInput.focused = this.focused && this.focusArea === "search";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private syncQueryFromInput(): void {
|
|
354
|
+
this.query = this.searchInput.getValue();
|
|
355
|
+
const rows = this.filteredRows;
|
|
356
|
+
if (rows.length === 0) {
|
|
357
|
+
this.selectedIndex = 0;
|
|
358
|
+
} else {
|
|
359
|
+
this.selectedIndex = Math.min(this.selectedIndex, rows.length - 1);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private setFocusArea(area: "search" | "list"): void {
|
|
364
|
+
this.focusArea = area;
|
|
365
|
+
this.syncSearchFocus();
|
|
366
|
+
this.tui.requestRender();
|
|
367
|
+
}
|
|
34
368
|
|
|
35
|
-
|
|
369
|
+
private setRows(skills: SkillIndex[], retainSelectedSkillIds: string[] = [], focusSkillId?: string): void {
|
|
370
|
+
this.rows = buildSkillRows(skills, new Set(retainSelectedSkillIds));
|
|
371
|
+
this.syncQueryFromInput();
|
|
372
|
+
|
|
373
|
+
const rows = this.filteredRows;
|
|
374
|
+
if (rows.length === 0) {
|
|
375
|
+
this.selectedIndex = 0;
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (focusSkillId) {
|
|
380
|
+
const focusIndex = rows.findIndex((row) => row.skillId === focusSkillId);
|
|
381
|
+
if (focusIndex >= 0) {
|
|
382
|
+
this.selectedIndex = focusIndex;
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
this.selectedIndex = Math.min(this.selectedIndex, rows.length - 1);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private toggleSelected(skillId: string): void {
|
|
391
|
+
const row = this.rows.find((entry) => entry.skillId === skillId);
|
|
392
|
+
if (!row) return;
|
|
393
|
+
row.selected = !row.selected;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private toggleCurrentSelection(): void {
|
|
397
|
+
const row = this.getCurrentRow();
|
|
398
|
+
if (!row) return;
|
|
399
|
+
this.toggleSelected(row.skillId);
|
|
400
|
+
this.summaryLines = [
|
|
401
|
+
`${row.selected ? "Selected" : "Cleared"} ${row.displayName}.`,
|
|
402
|
+
];
|
|
403
|
+
this.tui.requestRender();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private selectAllFiltered(): void {
|
|
407
|
+
const rows = this.filteredRows;
|
|
408
|
+
for (const row of rows) {
|
|
409
|
+
row.selected = true;
|
|
410
|
+
}
|
|
411
|
+
this.summaryLines = [
|
|
412
|
+
`Selected ${rows.length} visible skill${rows.length === 1 ? "" : "s"}.`,
|
|
413
|
+
];
|
|
414
|
+
this.tui.requestRender();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private clearSelection(): void {
|
|
418
|
+
for (const row of this.rows) {
|
|
419
|
+
row.selected = false;
|
|
420
|
+
}
|
|
421
|
+
this.summaryLines = ["Cleared all selections."];
|
|
422
|
+
this.tui.requestRender();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private async runMove(targetScope: SkillScope): Promise<void> {
|
|
426
|
+
const selectedIds = this.getSelectedIds();
|
|
427
|
+
await this.runAsyncAction(this.callbacks.moveSelected(targetScope, selectedIds));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private promptDelete(): void {
|
|
431
|
+
const selectedIds = this.getSelectedIds();
|
|
432
|
+
if (selectedIds.length === 0) {
|
|
433
|
+
this.summaryLines = ["Select one or more skills first."];
|
|
434
|
+
this.tui.requestRender();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
this.pendingDeleteConfirm = { skillIds: selectedIds };
|
|
439
|
+
this.summaryLines = [
|
|
440
|
+
`Delete ${selectedIds.length} selected skill${selectedIds.length === 1 ? "" : "s"}? Press y to confirm or n to cancel.`,
|
|
441
|
+
];
|
|
442
|
+
this.tui.requestRender();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private async runDeleteConfirmed(skillIds: string[]): Promise<void> {
|
|
446
|
+
await this.runAsyncAction(this.callbacks.deleteSelected(skillIds));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private closeModal(): void {
|
|
450
|
+
if (this.closed) return;
|
|
451
|
+
this.closed = true;
|
|
452
|
+
this.callbacks.close();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private async runAsyncAction(action: Promise<SkillBatchActionResult>): Promise<void> {
|
|
456
|
+
if (this.closed) return;
|
|
457
|
+
|
|
458
|
+
this.busy = true;
|
|
459
|
+
this.summaryLines = ["Applying skill changes…"];
|
|
460
|
+
this.tui.requestRender();
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
const result = await action;
|
|
464
|
+
if (this.closed) return;
|
|
465
|
+
this.setRows(result.skills, result.retainSelectedSkillIds, result.focusSkillId);
|
|
466
|
+
this.summaryLines = result.summaryLines;
|
|
467
|
+
} catch (error) {
|
|
468
|
+
if (!this.closed) {
|
|
469
|
+
this.summaryLines = [error instanceof Error ? error.message : String(error)];
|
|
470
|
+
}
|
|
471
|
+
} finally {
|
|
472
|
+
this.busy = false;
|
|
473
|
+
if (!this.closed) {
|
|
474
|
+
this.tui.requestRender();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private moveSelection(delta: number): void {
|
|
480
|
+
const rows = this.filteredRows;
|
|
481
|
+
if (rows.length === 0) return;
|
|
482
|
+
const next = this.selectedIndex + delta;
|
|
483
|
+
this.selectedIndex = Math.max(0, Math.min(next, rows.length - 1));
|
|
484
|
+
this.tui.requestRender();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private pageSelection(delta: number): void {
|
|
488
|
+
const pageSize = Math.max(5, this.getMaxVisibleRows() - 1);
|
|
489
|
+
this.moveSelection(delta * pageSize);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private getMaxVisibleRows(): number {
|
|
493
|
+
return Math.max(6, Math.min(14, this.tui.terminal.rows - 18));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private focusSearchWithOptionalInput(data?: string): void {
|
|
497
|
+
this.setFocusArea("search");
|
|
498
|
+
if (data) {
|
|
499
|
+
this.searchInput.handleInput(data);
|
|
500
|
+
this.syncQueryFromInput();
|
|
501
|
+
this.tui.requestRender();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private isPrintableInput(data: string): boolean {
|
|
506
|
+
return data.length === 1 && data >= " " && data !== "\x7f";
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
handleInput(data: string): void {
|
|
510
|
+
if (this.closed) return;
|
|
511
|
+
|
|
512
|
+
if (this.busy) {
|
|
513
|
+
if (matchesKey(data, Key.escape)) this.closeModal();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (this.pendingDeleteConfirm) {
|
|
518
|
+
if (data === "y" || data === "Y") {
|
|
519
|
+
const pending = this.pendingDeleteConfirm;
|
|
520
|
+
this.pendingDeleteConfirm = null;
|
|
521
|
+
void this.runDeleteConfirmed(pending.skillIds);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (data === "n" || data === "N" || matchesKey(data, Key.escape)) {
|
|
526
|
+
this.pendingDeleteConfirm = null;
|
|
527
|
+
this.summaryLines = ["Delete cancelled."];
|
|
528
|
+
this.tui.requestRender();
|
|
529
|
+
}
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (matchesKey(data, Key.escape)) {
|
|
534
|
+
this.closeModal();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (this.focusArea === "search") {
|
|
539
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.down)) {
|
|
540
|
+
this.setFocusArea("list");
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
this.searchInput.handleInput(data);
|
|
545
|
+
this.syncQueryFromInput();
|
|
546
|
+
this.tui.requestRender();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.slash)) {
|
|
551
|
+
this.focusSearchWithOptionalInput();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (matchesKey(data, Key.up)) {
|
|
555
|
+
this.moveSelection(-1);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (matchesKey(data, Key.down)) {
|
|
559
|
+
this.moveSelection(1);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
563
|
+
this.pageSelection(-1);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
567
|
+
this.pageSelection(1);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (matchesKey(data, Key.home)) {
|
|
571
|
+
this.selectedIndex = 0;
|
|
572
|
+
this.tui.requestRender();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (matchesKey(data, Key.end)) {
|
|
576
|
+
this.selectedIndex = Math.max(0, this.filteredRows.length - 1);
|
|
577
|
+
this.tui.requestRender();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (matchesKey(data, Key.space)) {
|
|
581
|
+
this.toggleCurrentSelection();
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (data === MEMORY_SKILLS_KEYMAP.selectAllFiltered) {
|
|
585
|
+
this.selectAllFiltered();
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (data === MEMORY_SKILLS_KEYMAP.clearSelection) {
|
|
589
|
+
this.clearSelection();
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (data === MEMORY_SKILLS_KEYMAP.moveGlobal) {
|
|
593
|
+
void this.runMove("global");
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (data === MEMORY_SKILLS_KEYMAP.moveProject) {
|
|
597
|
+
void this.runMove("project");
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (data === MEMORY_SKILLS_KEYMAP.deleteSelected) {
|
|
601
|
+
this.promptDelete();
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (this.isPrintableInput(data) && !["g", "p", "d", "a", "n"].includes(data)) {
|
|
605
|
+
this.focusSearchWithOptionalInput(data);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private renderFramedLine(content: string, width: number): string {
|
|
610
|
+
const innerWidth = Math.max(10, width - 4);
|
|
611
|
+
const padded = truncateToWidth(content, innerWidth, "");
|
|
612
|
+
const spaces = Math.max(0, innerWidth - visibleWidth(padded));
|
|
613
|
+
return `${this.theme.fg("borderAccent", "│")} ${padded}${" ".repeat(spaces)} ${this.theme.fg("borderAccent", "│")}`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private renderWrappedSection(lines: string[], width: number): string[] {
|
|
617
|
+
const rendered: string[] = [];
|
|
618
|
+
const innerWidth = Math.max(10, width - 4);
|
|
619
|
+
for (const line of lines) {
|
|
620
|
+
const wrapped = wrapTextWithAnsi(line, innerWidth);
|
|
621
|
+
if (wrapped.length === 0) {
|
|
622
|
+
rendered.push(this.renderFramedLine("", width));
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
for (const part of wrapped) {
|
|
626
|
+
rendered.push(this.renderFramedLine(part, width));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return rendered;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
render(width: number): string[] {
|
|
633
|
+
const safeWidth = Math.max(60, width);
|
|
634
|
+
const top = this.theme.fg("borderAccent", `┌${"─".repeat(Math.max(1, safeWidth - 2))}┐`);
|
|
635
|
+
const bottom = this.theme.fg("borderAccent", `└${"─".repeat(Math.max(1, safeWidth - 2))}┘`);
|
|
636
|
+
const lines: string[] = [top];
|
|
637
|
+
|
|
638
|
+
const projectName = this.callbacks.projectName ? ` · project: ${this.callbacks.projectName}` : "";
|
|
639
|
+
const title = this.theme.fg("accent", this.theme.bold(`🧠 Procedural Skills${projectName}`));
|
|
640
|
+
lines.push(this.renderFramedLine(title, safeWidth));
|
|
641
|
+
|
|
642
|
+
const searchHint = this.focusArea === "search"
|
|
643
|
+
? this.theme.fg("accent", "search")
|
|
644
|
+
: this.theme.fg("dim", "search");
|
|
645
|
+
const searchLine = this.searchInput.render(Math.max(10, safeWidth - 17))[0] ?? "";
|
|
646
|
+
lines.push(this.renderFramedLine(`${searchHint}: ${searchLine}`, safeWidth));
|
|
647
|
+
|
|
648
|
+
const filteredRows = this.filteredRows;
|
|
649
|
+
const selectedCount = this.getSelectedIds().length;
|
|
650
|
+
lines.push(this.renderFramedLine(
|
|
651
|
+
this.theme.fg(
|
|
652
|
+
"dim",
|
|
653
|
+
`${filteredRows.length} visible · ${this.rows.length} total · ${selectedCount} selected${this.busy ? " · working…" : ""}`,
|
|
654
|
+
),
|
|
655
|
+
safeWidth,
|
|
656
|
+
));
|
|
657
|
+
|
|
658
|
+
lines.push(this.renderFramedLine("", safeWidth));
|
|
659
|
+
|
|
660
|
+
if (filteredRows.length === 0) {
|
|
661
|
+
const emptyMessage = this.rows.length === 0 ? "No skills found yet." : "No skills match the current search.";
|
|
662
|
+
lines.push(this.renderFramedLine(this.theme.fg("warning", emptyMessage), safeWidth));
|
|
663
|
+
lines.push(this.renderFramedLine("", safeWidth));
|
|
664
|
+
} else {
|
|
665
|
+
const maxVisible = this.getMaxVisibleRows();
|
|
666
|
+
const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), filteredRows.length - maxVisible));
|
|
667
|
+
const end = Math.min(filteredRows.length, start + maxVisible);
|
|
668
|
+
const visibleRows = filteredRows.slice(start, end);
|
|
669
|
+
|
|
670
|
+
for (let i = 0; i < visibleRows.length; i++) {
|
|
671
|
+
const row = visibleRows[i]!;
|
|
672
|
+
const absoluteIndex = start + i;
|
|
673
|
+
const cursor = absoluteIndex === this.selectedIndex ? this.theme.fg("accent", "›") : " ";
|
|
674
|
+
const check = row.selected ? this.theme.fg("accent", "[x]") : this.theme.fg("dim", "[ ]");
|
|
675
|
+
const scope = row.scope === "global"
|
|
676
|
+
? this.theme.fg("accent", "[G]")
|
|
677
|
+
: this.theme.fg("warning", "[P]");
|
|
678
|
+
const baseText = `${cursor} ${check} ${scope} ${row.displayName}`;
|
|
679
|
+
const lineText = absoluteIndex === this.selectedIndex
|
|
680
|
+
? this.theme.bg("selectedBg", truncateToWidth(baseText, Math.max(10, safeWidth - 4), ""))
|
|
681
|
+
: truncateToWidth(baseText, Math.max(10, safeWidth - 4), "");
|
|
682
|
+
lines.push(this.renderFramedLine(lineText, safeWidth));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (start > 0 || end < filteredRows.length) {
|
|
686
|
+
lines.push(this.renderFramedLine(this.theme.fg("dim", `Showing ${start + 1}-${end} of ${filteredRows.length}`), safeWidth));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
lines.push(this.renderFramedLine("", safeWidth));
|
|
690
|
+
const currentRow = this.getCurrentRow();
|
|
691
|
+
if (currentRow) {
|
|
692
|
+
lines.push(this.renderFramedLine(this.theme.fg("accent", `Focused: ${currentRow.displayName}`), safeWidth));
|
|
693
|
+
lines.push(...this.renderWrappedSection([
|
|
694
|
+
currentRow.description || "(no description)",
|
|
695
|
+
this.theme.fg("dim", currentRow.skillId),
|
|
696
|
+
], safeWidth));
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
lines.push(this.renderFramedLine("", safeWidth));
|
|
701
|
+
lines.push(this.renderFramedLine(this.theme.fg("accent", "Last action"), safeWidth));
|
|
702
|
+
lines.push(...this.renderWrappedSection(this.summaryLines, safeWidth));
|
|
703
|
+
lines.push(this.renderFramedLine("", safeWidth));
|
|
704
|
+
|
|
705
|
+
const help = this.pendingDeleteConfirm
|
|
706
|
+
? "Confirm delete: y yes · n no · esc cancel"
|
|
707
|
+
: this.callbacks.projectName
|
|
708
|
+
? "↑↓ move · space select · / search · tab switch · g global · p project · d delete · a all · n none · esc close"
|
|
709
|
+
: "↑↓ move · space select · / search · tab switch · g global · p project (disabled) · d delete · a all · n none · esc close";
|
|
710
|
+
lines.push(this.renderFramedLine(this.theme.fg("dim", help), safeWidth));
|
|
711
|
+
lines.push(bottom);
|
|
712
|
+
return lines;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export function registerSkillsCommand(pi: ExtensionAPI, store: SkillStore): void {
|
|
717
|
+
pi.registerCommand("memory-skills", {
|
|
718
|
+
description: "Manage global and active-project procedural skills",
|
|
719
|
+
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
720
|
+
const skills = await store.loadIndex();
|
|
721
|
+
const projectName = store.getProjectName();
|
|
722
|
+
|
|
723
|
+
if (!ctx.hasUI || typeof ctx.ui.custom !== "function") {
|
|
724
|
+
ctx.ui.notify(formatSkillsList(skills, projectName), "info");
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const initialRows = buildSkillRows(skills);
|
|
729
|
+
|
|
730
|
+
try {
|
|
731
|
+
await ctx.ui.custom<void>(
|
|
732
|
+
(tui, theme, _keybindings, done) => new SkillsManagerModal(
|
|
733
|
+
tui,
|
|
734
|
+
theme,
|
|
735
|
+
initialRows,
|
|
736
|
+
{
|
|
737
|
+
moveSelected: (scope, skillIds) => moveSelectedSkills(store, skillIds, scope),
|
|
738
|
+
deleteSelected: (skillIds) => deleteSelectedSkills(store, skillIds),
|
|
739
|
+
close: () => done(undefined),
|
|
740
|
+
projectName,
|
|
741
|
+
},
|
|
742
|
+
),
|
|
743
|
+
{
|
|
744
|
+
overlay: true,
|
|
745
|
+
overlayOptions: {
|
|
746
|
+
anchor: "center",
|
|
747
|
+
width: "88%",
|
|
748
|
+
minWidth: 72,
|
|
749
|
+
maxHeight: "85%",
|
|
750
|
+
margin: 1,
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
);
|
|
754
|
+
} catch {
|
|
755
|
+
const latestSkills = await store.loadIndex();
|
|
756
|
+
ctx.ui.notify(
|
|
757
|
+
"Interactive skills manager unavailable in this runtime; showing read-only list fallback.",
|
|
758
|
+
"warning",
|
|
759
|
+
);
|
|
760
|
+
ctx.ui.notify(formatSkillsList(latestSkills, projectName), "info");
|
|
761
|
+
}
|
|
36
762
|
},
|
|
37
763
|
});
|
|
38
764
|
}
|