@kmiyh/pi-skills-menu 1.0.1

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.
@@ -0,0 +1,553 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
3
+ import {
4
+ Container,
5
+ type Component,
6
+ Editor,
7
+ type Focusable,
8
+ Input,
9
+ Key,
10
+ matchesKey,
11
+ Spacer,
12
+ Text,
13
+ truncateToWidth,
14
+ type TUI,
15
+ } from "@mariozechner/pi-tui";
16
+ import { normalizeSkillName, type SkillCreationAnswers } from "../create-skill.js";
17
+ import { isDeletableSkill } from "../delete-skill.js";
18
+ import type { SkillEntry, SkillRegistry } from "../types.js";
19
+
20
+ export type SkillsMenuSelection =
21
+ | { type: "skill"; skill: SkillEntry; selectedIndex: number; query: string }
22
+ | { type: "create"; answers: SkillCreationAnswers; selectedIndex: number; query: string }
23
+ | { type: "preview"; skill: SkillEntry; selectedIndex: number; query: string }
24
+ | { type: "delete"; skill: SkillEntry; selectedIndex: number; query: string }
25
+ | null;
26
+
27
+ type CreateTextStepId = "name" | "description";
28
+ type CreateStep = { id: CreateTextStepId; title: string; hint: string; optional: boolean; kind: "text" };
29
+
30
+ const CREATE_STEPS: CreateStep[] = [
31
+ { id: "name", title: "Name", hint: "Use lowercase letters, numbers, and hyphens, for example react-review.", optional: false, kind: "text" },
32
+ { id: "description", title: "Description", hint: "Describe what the skill does and when it should be used in one clear sentence.", optional: false, kind: "text" },
33
+ ];
34
+
35
+ function getScopeLabel(skill: SkillEntry): string {
36
+ if (skill.scope === "project") return "project";
37
+ if (skill.scope === "user") return "global";
38
+ return "local";
39
+ }
40
+
41
+ function getPackageLabel(skill: SkillEntry): string | undefined {
42
+ return skill.origin === "package" && skill.source ? skill.source : undefined;
43
+ }
44
+
45
+ class SingleLineText implements Component {
46
+ constructor(
47
+ private readonly text: string,
48
+ private readonly ellipsis = "...",
49
+ ) {}
50
+
51
+ render(width: number): string[] {
52
+ return [truncateToWidth(this.text, width, this.ellipsis)];
53
+ }
54
+
55
+ invalidate(): void {}
56
+ }
57
+
58
+ class PrefixedEditor implements Component {
59
+ constructor(
60
+ private readonly editor: Editor,
61
+ private readonly prefix = "> ",
62
+ ) {}
63
+
64
+ render(width: number): string[] {
65
+ const editorWidth = Math.max(1, width - this.prefix.length);
66
+ const rendered = this.editor.render(editorWidth);
67
+ const lines = rendered.length >= 2 ? rendered.slice(1, -1) : rendered;
68
+ if (lines.length === 0) {
69
+ return [this.prefix];
70
+ }
71
+ return lines.map((line, index) => `${index === 0 ? this.prefix : " "}${line}`);
72
+ }
73
+
74
+ invalidate(): void {
75
+ this.editor.invalidate();
76
+ }
77
+ }
78
+
79
+ type BrowseRenderEntry =
80
+ | { kind: "create" }
81
+ | { kind: "header"; label: string }
82
+ | { kind: "skill"; skill: SkillEntry };
83
+
84
+ class SkillsSelectorComponent extends Container implements Focusable {
85
+ private input = new Input();
86
+ private descriptionEditor: Editor;
87
+ private listContainer = new Container();
88
+ private footerText = new Text("", 1, 0);
89
+ private filteredSkills: SkillEntry[] = [];
90
+ private selectedIndex: number;
91
+ private readonly maxVisible = 12;
92
+ private readonly createLabel = "Create new skill";
93
+ private mode: "browse" | "create" = "browse";
94
+ private createStepIndex = 0;
95
+ private createValues: Record<CreateTextStepId, string> = {
96
+ name: "",
97
+ description: "",
98
+ };
99
+ private createError: string | undefined;
100
+ private browseQuery: string;
101
+
102
+ private _focused = false;
103
+ get focused(): boolean {
104
+ return this._focused;
105
+ }
106
+ set focused(value: boolean) {
107
+ this._focused = value;
108
+ this.input.focused = value && (this.mode === "browse" || this.currentCreateStep.id === "name");
109
+ this.descriptionEditor.focused = value && this.mode === "create" && this.currentCreateStep.id === "description";
110
+ }
111
+
112
+ constructor(
113
+ private readonly skills: SkillEntry[],
114
+ private readonly theme: ExtensionContext["ui"]["theme"],
115
+ private readonly done: (value: SkillsMenuSelection) => void,
116
+ tui: TUI,
117
+ initialSelectedIndex = 0,
118
+ initialQuery = "",
119
+ ) {
120
+ super();
121
+ this.descriptionEditor = new Editor(tui, {
122
+ borderColor: (text: string) => " ".repeat(text.length),
123
+ selectList: {
124
+ selectedPrefix: (text: string) => this.theme.fg("accent", text),
125
+ selectedText: (text: string) => this.theme.bg("selectedBg", this.theme.fg("text", text)),
126
+ description: (text: string) => this.theme.fg("muted", text),
127
+ scrollInfo: (text: string) => this.theme.fg("dim", text),
128
+ noMatch: (text: string) => this.theme.fg("warning", text),
129
+ },
130
+ });
131
+ this.descriptionEditor.onSubmit = () => {
132
+ this.goToNextCreateStep();
133
+ };
134
+ this.filteredSkills = skills;
135
+ this.selectedIndex = Math.max(0, initialSelectedIndex);
136
+ this.browseQuery = initialQuery;
137
+ this.input.setValue(initialQuery);
138
+
139
+ this.refresh();
140
+ }
141
+
142
+ private rebuildLayout(showInput: boolean): void {
143
+ this.clear();
144
+ this.addChild(new DynamicBorder((s: string) => this.theme.fg("accent", s)));
145
+ this.addChild(this.header);
146
+ this.addChild(new Spacer(1));
147
+ if (showInput) {
148
+ this.addChild(this.input);
149
+ this.addChild(new Spacer(1));
150
+ }
151
+ this.addChild(this.listContainer);
152
+ this.addChild(new Spacer(1));
153
+ this.addChild(this.footerText);
154
+ this.addChild(new DynamicBorder((s: string) => this.theme.fg("accent", s)));
155
+ }
156
+
157
+ private readonly header = new Text("", 1, 0);
158
+
159
+ private filterSkills(query: string): SkillEntry[] {
160
+ const trimmed = query.trim().toLowerCase();
161
+ if (!trimmed) return this.skills;
162
+
163
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
164
+ return this.skills.filter((skill) => tokens.every((token) => skill.name.toLowerCase().includes(token)));
165
+ }
166
+
167
+ private orderBrowseSkills(skills: SkillEntry[]): SkillEntry[] {
168
+ const ownSkills = skills.filter((skill) => isDeletableSkill(skill));
169
+ const otherSkills = skills.filter((skill) => !isDeletableSkill(skill));
170
+ return [...ownSkills, ...otherSkills];
171
+ }
172
+
173
+ private buildBrowseEntries(): BrowseRenderEntry[] {
174
+ const ownSkills = this.filteredSkills.filter((skill) => isDeletableSkill(skill));
175
+ const otherSkills = this.filteredSkills.filter((skill) => !isDeletableSkill(skill));
176
+ const entries: BrowseRenderEntry[] = [{ kind: "create" }];
177
+
178
+ if (ownSkills.length > 0) {
179
+ entries.push({ kind: "header", label: "Your Skills" });
180
+ entries.push(...ownSkills.map((skill) => ({ kind: "skill" as const, skill })));
181
+ }
182
+ if (otherSkills.length > 0) {
183
+ entries.push({ kind: "header", label: "Library Skills" });
184
+ entries.push(...otherSkills.map((skill) => ({ kind: "skill" as const, skill })));
185
+ }
186
+
187
+ return entries;
188
+ }
189
+
190
+ private getSelectableCount(): number {
191
+ return this.filteredSkills.length + 1;
192
+ }
193
+
194
+ private getSelectedSkill(): SkillEntry | undefined {
195
+ if (this.selectedIndex === 0) return undefined;
196
+ return this.filteredSkills[this.selectedIndex - 1];
197
+ }
198
+
199
+ private getCurrentQuery(): string {
200
+ return this.input.getValue();
201
+ }
202
+
203
+ private get currentCreateStep(): CreateStep {
204
+ return CREATE_STEPS[this.createStepIndex]!;
205
+ }
206
+
207
+ private setBrowseInputValue(value: string): void {
208
+ this.browseQuery = value;
209
+ this.input.setValue(value);
210
+ }
211
+
212
+ private enterCreateMode(): void {
213
+ this.mode = "create";
214
+ this.createStepIndex = 0;
215
+ this.createError = undefined;
216
+ this.syncCreateInput();
217
+ this.refresh();
218
+ }
219
+
220
+ private exitCreateMode(): void {
221
+ this.mode = "browse";
222
+ this.createError = undefined;
223
+ this.setBrowseInputValue(this.browseQuery);
224
+ this.refresh();
225
+ }
226
+
227
+ private syncCreateInput(): void {
228
+ const step = this.currentCreateStep;
229
+ if (step.id === "name") {
230
+ this.input.setValue(this.createValues.name);
231
+ this.input.focused = this._focused;
232
+ this.descriptionEditor.focused = false;
233
+ return;
234
+ }
235
+ this.descriptionEditor.setText(this.createValues.description);
236
+ this.input.focused = false;
237
+ this.descriptionEditor.focused = this._focused;
238
+ }
239
+
240
+ private persistCreateInput(): void {
241
+ const step = this.currentCreateStep;
242
+ if (step.id === "name") {
243
+ this.createValues.name = this.input.getValue();
244
+ return;
245
+ }
246
+ this.createValues.description = this.descriptionEditor.getText();
247
+ }
248
+
249
+ private validateCreateStep(): boolean {
250
+ this.persistCreateInput();
251
+ const step = this.currentCreateStep;
252
+ if (step.kind === "text" && !step.optional) {
253
+ const value = this.createValues[step.id].trim();
254
+ if (!value) {
255
+ this.createError = `${step.title} is required.`;
256
+ this.refresh();
257
+ return false;
258
+ }
259
+ if (step.id === "name" && !normalizeSkillName(value)) {
260
+ this.createError = "Name must contain letters, numbers, or hyphens.";
261
+ this.refresh();
262
+ return false;
263
+ }
264
+ }
265
+ this.createError = undefined;
266
+ return true;
267
+ }
268
+
269
+ private goToPreviousCreateStep(): void {
270
+ this.persistCreateInput();
271
+ if (this.createStepIndex === 0) return;
272
+ this.createError = undefined;
273
+ this.createStepIndex -= 1;
274
+ this.syncCreateInput();
275
+ this.refresh();
276
+ }
277
+
278
+ private submitCreate(): void {
279
+ this.persistCreateInput();
280
+ const name = normalizeSkillName(this.createValues.name);
281
+ if (!name) {
282
+ this.createStepIndex = 0;
283
+ this.syncCreateInput();
284
+ this.createError = "Name is required.";
285
+ this.refresh();
286
+ return;
287
+ }
288
+ if (!this.createValues.description.trim()) {
289
+ this.createStepIndex = 1;
290
+ this.syncCreateInput();
291
+ this.createError = "Description is required.";
292
+ this.refresh();
293
+ return;
294
+ }
295
+
296
+ this.done({
297
+ type: "create",
298
+ answers: {
299
+ name,
300
+ description: this.createValues.description.trim(),
301
+ allowedTools: [],
302
+ location: "project",
303
+ },
304
+ selectedIndex: this.selectedIndex,
305
+ query: this.browseQuery,
306
+ });
307
+ }
308
+
309
+ private goToNextCreateStep(): void {
310
+ if (!this.validateCreateStep()) return;
311
+ if (this.createStepIndex >= CREATE_STEPS.length - 1) {
312
+ this.submitCreate();
313
+ return;
314
+ }
315
+ this.createStepIndex += 1;
316
+ this.syncCreateInput();
317
+ this.refresh();
318
+ }
319
+
320
+ private refreshBrowse(): void {
321
+ this.rebuildLayout(true);
322
+ this.header.setText(this.theme.fg("accent", this.theme.bold("Skills")));
323
+ this.filteredSkills = this.orderBrowseSkills(this.filterSkills(this.browseQuery));
324
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.getSelectableCount() - 1));
325
+ const selectedSkill = this.getSelectedSkill();
326
+ const footer = !this.browseQuery && selectedSkill && isDeletableSkill(selectedSkill)
327
+ ? "type to search • enter insert • tab preview • backspace delete • esc close"
328
+ : "type to search • enter insert • tab preview • esc close";
329
+ this.footerText.setText(this.theme.fg("dim", footer));
330
+ this.renderBrowseList();
331
+ }
332
+
333
+ private renderBrowseList(): void {
334
+ this.listContainer.clear();
335
+ const descriptionEllipsis = this.theme.fg("dim", "...");
336
+ const entries = this.buildBrowseEntries();
337
+ let selectedDisplayIndex = 0;
338
+ let selectableIndex = 0;
339
+
340
+ for (let i = 0; i < entries.length; i++) {
341
+ const entry = entries[i]!;
342
+ if (entry.kind === "create" || entry.kind === "skill") {
343
+ if (selectableIndex === this.selectedIndex) {
344
+ selectedDisplayIndex = i;
345
+ break;
346
+ }
347
+ selectableIndex += 1;
348
+ }
349
+ }
350
+
351
+ const startIndex = Math.max(0, Math.min(selectedDisplayIndex - Math.floor(this.maxVisible / 2), Math.max(0, entries.length - this.maxVisible)));
352
+ const endIndex = Math.min(startIndex + this.maxVisible, entries.length);
353
+ selectableIndex = 0;
354
+
355
+ for (let i = 0; i < endIndex; i++) {
356
+ const entry = entries[i]!;
357
+ const isSelectable = entry.kind === "create" || entry.kind === "skill";
358
+ const isSelected = isSelectable && selectableIndex === this.selectedIndex;
359
+ if (i >= startIndex) {
360
+ if (entry.kind === "header") {
361
+ this.listContainer.addChild(new Spacer(1));
362
+ this.listContainer.addChild(new SingleLineText(this.theme.fg("muted", this.theme.bold(entry.label)), descriptionEllipsis));
363
+ } else if (entry.kind === "create") {
364
+ const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
365
+ const label = isSelected ? this.theme.fg("accent", this.createLabel) : this.createLabel;
366
+ const desc = this.theme.fg("dim", " — generate and save a new skill");
367
+ this.listContainer.addChild(new SingleLineText(`${prefix}${label}${desc}`, descriptionEllipsis));
368
+ } else {
369
+ const skill = entry.skill;
370
+ const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
371
+ const name = isSelected ? this.theme.fg("accent", skill.name) : skill.name;
372
+ const scope = this.theme.fg("muted", ` [${getScopeLabel(skill)}]`);
373
+ const packageLabel = getPackageLabel(skill);
374
+ const source = packageLabel ? this.theme.fg("muted", ` - [${packageLabel}]`) : "";
375
+ const descriptionPrefix = packageLabel ? " " : " - ";
376
+ const description = this.theme.fg("dim", `${descriptionPrefix}${skill.description}`);
377
+ this.listContainer.addChild(new SingleLineText(`${prefix}${name}${scope}${source}${description}`, descriptionEllipsis));
378
+ }
379
+ }
380
+ if (isSelectable) {
381
+ selectableIndex += 1;
382
+ }
383
+ }
384
+ }
385
+
386
+ private refreshCreate(): void {
387
+ const step = this.currentCreateStep;
388
+ this.rebuildLayout(step.id === "name");
389
+ this.header.setText(this.theme.fg("accent", this.theme.bold(`${step.title} (${step.optional ? "optional" : "required"})`)));
390
+ this.footerText.setText(this.theme.fg("dim", this.getCreateFooter(step)));
391
+ this.renderCreateList();
392
+ }
393
+
394
+ private getCreateFooter(step: CreateStep): string {
395
+ if (step.id === "description") {
396
+ return "enter create • ctrl+j newline • alt+← back • esc cancel";
397
+ }
398
+ return this.createStepIndex >= CREATE_STEPS.length - 1
399
+ ? "enter create • alt+← back • esc cancel"
400
+ : "enter next • alt+← back • alt+→ next • esc cancel";
401
+ }
402
+
403
+ private renderCreateList(): void {
404
+ this.listContainer.clear();
405
+ const step = this.currentCreateStep;
406
+
407
+ if (step.id === "description") {
408
+ this.listContainer.addChild(new PrefixedEditor(this.descriptionEditor));
409
+ if (step.hint) {
410
+ this.listContainer.addChild(new Spacer(1));
411
+ this.listContainer.addChild(new Text(this.theme.fg("dim", step.hint), 1, 0));
412
+ }
413
+ } else if (step.hint) {
414
+ this.listContainer.addChild(new Text(this.theme.fg("dim", step.hint), 1, 0));
415
+ }
416
+
417
+ if (this.createError) {
418
+ this.listContainer.addChild(new Spacer(1));
419
+ this.listContainer.addChild(new Text(this.theme.fg("error", this.createError), 1, 0));
420
+ }
421
+ }
422
+
423
+ private refresh(): void {
424
+ if (this.mode === "browse") this.refreshBrowse();
425
+ else this.refreshCreate();
426
+ }
427
+
428
+ handleInput(data: string): void {
429
+ if (this.mode === "browse") {
430
+ this.handleBrowseInput(data);
431
+ return;
432
+ }
433
+ this.handleCreateInput(data);
434
+ }
435
+
436
+ private handleBrowseInput(data: string): void {
437
+ if (matchesKey(data, Key.up)) {
438
+ this.selectedIndex = this.selectedIndex === 0 ? this.getSelectableCount() - 1 : this.selectedIndex - 1;
439
+ this.refreshBrowse();
440
+ return;
441
+ }
442
+ if (matchesKey(data, Key.down)) {
443
+ this.selectedIndex = this.selectedIndex === this.getSelectableCount() - 1 ? 0 : this.selectedIndex + 1;
444
+ this.refreshBrowse();
445
+ return;
446
+ }
447
+ if (matchesKey(data, Key.enter)) {
448
+ if (this.selectedIndex === 0) {
449
+ this.enterCreateMode();
450
+ return;
451
+ }
452
+ const skill = this.getSelectedSkill();
453
+ if (skill) {
454
+ this.done({ type: "skill", skill, selectedIndex: this.selectedIndex, query: this.browseQuery });
455
+ }
456
+ return;
457
+ }
458
+ if (matchesKey(data, Key.tab)) {
459
+ const skill = this.getSelectedSkill();
460
+ if (skill) {
461
+ this.done({ type: "preview", skill, selectedIndex: this.selectedIndex, query: this.browseQuery });
462
+ }
463
+ return;
464
+ }
465
+ if (matchesKey(data, Key.backspace) && !this.input.getValue()) {
466
+ const skill = this.getSelectedSkill();
467
+ if (skill && isDeletableSkill(skill)) {
468
+ this.done({ type: "delete", skill, selectedIndex: this.selectedIndex, query: this.browseQuery });
469
+ }
470
+ return;
471
+ }
472
+ if (matchesKey(data, Key.escape)) {
473
+ if (this.input.getValue()) {
474
+ this.setBrowseInputValue("");
475
+ this.refreshBrowse();
476
+ } else {
477
+ this.done(null);
478
+ }
479
+ return;
480
+ }
481
+
482
+ this.input.handleInput(data);
483
+ this.browseQuery = this.input.getValue();
484
+ this.refreshBrowse();
485
+ }
486
+
487
+ private handleCreateInput(data: string): void {
488
+ if (matchesKey(data, Key.escape)) {
489
+ this.exitCreateMode();
490
+ return;
491
+ }
492
+ if (matchesKey(data, Key.alt("left"))) {
493
+ this.goToPreviousCreateStep();
494
+ return;
495
+ }
496
+ if (matchesKey(data, Key.alt("right"))) {
497
+ this.goToNextCreateStep();
498
+ return;
499
+ }
500
+ if (matchesKey(data, Key.enter) && this.currentCreateStep.id === "name") {
501
+ this.goToNextCreateStep();
502
+ return;
503
+ }
504
+
505
+ this.createError = undefined;
506
+ const step = this.currentCreateStep;
507
+ if (step.id === "name") {
508
+ this.input.handleInput(data);
509
+ this.createValues.name = this.input.getValue();
510
+ this.refreshCreate();
511
+ return;
512
+ }
513
+ this.descriptionEditor.handleInput(data);
514
+ this.createValues.description = this.descriptionEditor.getText();
515
+ this.refreshCreate();
516
+ }
517
+ }
518
+
519
+ export async function showSkillsSelector(
520
+ ctx: ExtensionContext,
521
+ registry: SkillRegistry,
522
+ initialSelectedIndex = 0,
523
+ initialQuery = "",
524
+ ): Promise<SkillsMenuSelection> {
525
+ return await ctx.ui.custom<SkillsMenuSelection>((tui, _theme, _kb, done) => {
526
+ const component = new SkillsSelectorComponent(
527
+ registry.skills,
528
+ ctx.ui.theme,
529
+ done,
530
+ tui,
531
+ initialSelectedIndex,
532
+ initialQuery,
533
+ );
534
+ return {
535
+ get focused() {
536
+ return component.focused;
537
+ },
538
+ set focused(value: boolean) {
539
+ component.focused = value;
540
+ },
541
+ render(width: number) {
542
+ return component.render(width);
543
+ },
544
+ invalidate() {
545
+ component.invalidate();
546
+ },
547
+ handleInput(data: string) {
548
+ component.handleInput(data);
549
+ tui.requestRender();
550
+ },
551
+ };
552
+ });
553
+ }