@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,767 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { rename as renamePath } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
5
+ import { getMarkdownTheme, parseFrontmatter, stripFrontmatter } from "@mariozechner/pi-coding-agent";
6
+ import {
7
+ Container,
8
+ type Component,
9
+ Editor,
10
+ type Focusable,
11
+ Input,
12
+ Key,
13
+ Markdown,
14
+ matchesKey,
15
+ Spacer,
16
+ Text,
17
+ truncateToWidth,
18
+ type TUI,
19
+ visibleWidth,
20
+ } from "@mariozechner/pi-tui";
21
+ import { normalizeSkillName } from "../create-skill.js";
22
+ import { isDeletableSkill } from "../delete-skill.js";
23
+ import type { SkillEntry } from "../types.js";
24
+
25
+ interface ParsedSkillDocument {
26
+ name: string;
27
+ description: string;
28
+ frontmatter: Record<string, unknown>;
29
+ content: string;
30
+ raw: string;
31
+ }
32
+
33
+ type MessageTone = "dim" | "success" | "error";
34
+
35
+ function getSkillLocation(skill: SkillEntry): string {
36
+ return skill.origin === "package" ? skill.source : skill.path;
37
+ }
38
+
39
+ function getSkillLocationLabel(skill: SkillEntry): string {
40
+ return skill.origin === "package" ? "package" : "path";
41
+ }
42
+
43
+ function formatScalar(value: unknown): string {
44
+ if (typeof value === "string") {
45
+ return value;
46
+ }
47
+ if (typeof value === "number" || typeof value === "boolean") {
48
+ return String(value);
49
+ }
50
+ if (value === null) {
51
+ return "null";
52
+ }
53
+ return JSON.stringify(value);
54
+ }
55
+
56
+ function formatYamlValue(key: string, value: unknown, indent = ""): string[] {
57
+ if (typeof value === "string" && value.includes("\n")) {
58
+ return [`${indent}${key}: |`, ...value.split("\n").map((line) => `${indent} ${line}`)];
59
+ }
60
+
61
+ if (Array.isArray(value)) {
62
+ if (value.length === 0) {
63
+ return [`${indent}${key}: []`];
64
+ }
65
+ return [
66
+ `${indent}${key}:`,
67
+ ...value.flatMap((item) => {
68
+ if (item && typeof item === "object") {
69
+ return [
70
+ `${indent} -`,
71
+ ...Object.entries(item as Record<string, unknown>).flatMap(([nestedKey, nestedValue]) =>
72
+ formatYamlValue(nestedKey, nestedValue, `${indent} `),
73
+ ),
74
+ ];
75
+ }
76
+ return [`${indent} - ${formatScalar(item)}`];
77
+ }),
78
+ ];
79
+ }
80
+
81
+ if (value && typeof value === "object") {
82
+ const entries = Object.entries(value as Record<string, unknown>);
83
+ if (entries.length === 0) {
84
+ return [`${indent}${key}: {}`];
85
+ }
86
+ return [
87
+ `${indent}${key}:`,
88
+ ...entries.flatMap(([nestedKey, nestedValue]) => formatYamlValue(nestedKey, nestedValue, `${indent} `)),
89
+ ];
90
+ }
91
+
92
+ return [`${indent}${key}: ${formatScalar(value)}`];
93
+ }
94
+
95
+ function buildFrontmatterBlock(skill: SkillEntry): string {
96
+ const frontmatter = skill.frontmatter ?? {
97
+ name: skill.name,
98
+ description: skill.description,
99
+ };
100
+ const lines = Object.entries(frontmatter).flatMap(([key, value]) => formatYamlValue(key, value));
101
+ return ["---", ...lines, "---"].join("\n");
102
+ }
103
+
104
+ function buildSkillDocument(skill: SkillEntry): string {
105
+ const frontmatter = buildFrontmatterBlock(skill);
106
+ const content = skill.content.trim();
107
+ return content ? `${frontmatter}\n\n${content}\n` : `${frontmatter}\n`;
108
+ }
109
+
110
+ function buildEditableSkillDocument(skill: SkillEntry, raw?: string): string {
111
+ const source = raw ?? buildSkillDocument(skill);
112
+ const parsed = parseFrontmatter<Record<string, unknown>>(source);
113
+ const frontmatter = { ...parsed.frontmatter };
114
+ delete frontmatter.name;
115
+ const editableBlock = ["---", ...Object.entries(frontmatter).flatMap(([key, value]) => formatYamlValue(key, value)), "---"].join("\n");
116
+ const content = stripFrontmatter(source).trim();
117
+ return content ? `${editableBlock}\n\n${content}\n` : `${editableBlock}\n`;
118
+ }
119
+
120
+ function readSkillDocument(skill: SkillEntry): string {
121
+ try {
122
+ return readFileSync(skill.path, "utf8");
123
+ } catch {
124
+ return buildSkillDocument(skill);
125
+ }
126
+ }
127
+
128
+ function parseSkillDocument(raw: string, expectedName: string): ParsedSkillDocument {
129
+ const parsed = parseFrontmatter<Record<string, unknown>>(raw);
130
+ const name = typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name.trim() : "";
131
+ const description = typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description.trim() : "";
132
+
133
+ if (!name || !description) {
134
+ throw new Error("Skill must include frontmatter fields 'name' and 'description'");
135
+ }
136
+ if (name !== expectedName) {
137
+ throw new Error(`Frontmatter name must stay '${expectedName}'`);
138
+ }
139
+
140
+ return {
141
+ name,
142
+ description,
143
+ frontmatter: Object.fromEntries(Object.entries(parsed.frontmatter).filter(([, value]) => value !== undefined)),
144
+ content: stripFrontmatter(raw).trim(),
145
+ raw: raw.trim() + "\n",
146
+ };
147
+ }
148
+
149
+ function parseEditableSkillDocument(raw: string, expectedName: string): ParsedSkillDocument {
150
+ const parsed = parseFrontmatter<Record<string, unknown>>(raw);
151
+ if (typeof parsed.frontmatter.name === "string") {
152
+ throw new Error("Name is immutable here. Use Rename instead.");
153
+ }
154
+ const frontmatter: Record<string, unknown> = {
155
+ name: expectedName,
156
+ ...Object.fromEntries(Object.entries(parsed.frontmatter).filter(([, value]) => value !== undefined)),
157
+ };
158
+ const description = typeof frontmatter.description === "string" ? frontmatter.description.trim() : "";
159
+ if (!description) {
160
+ throw new Error("Skill must include frontmatter field 'description'");
161
+ }
162
+ const content = stripFrontmatter(raw).trim();
163
+ const fullRaw = content
164
+ ? `${["---", ...Object.entries(frontmatter).flatMap(([key, value]) => formatYamlValue(key, value)), "---"].join("\n")}\n\n${content}\n`
165
+ : `${["---", ...Object.entries(frontmatter).flatMap(([key, value]) => formatYamlValue(key, value)), "---"].join("\n")}\n`;
166
+ return {
167
+ name: expectedName,
168
+ description,
169
+ frontmatter,
170
+ content,
171
+ raw: fullRaw,
172
+ };
173
+ }
174
+
175
+ function toUpdatedSkill(skill: SkillEntry, parsed: ParsedSkillDocument): SkillEntry {
176
+ return {
177
+ ...skill,
178
+ name: parsed.name,
179
+ description: parsed.description,
180
+ content: parsed.content,
181
+ frontmatter: parsed.frontmatter,
182
+ };
183
+ }
184
+
185
+ function getToneText(
186
+ theme: ExtensionContext["ui"]["theme"],
187
+ tone: MessageTone,
188
+ text: string,
189
+ ): string {
190
+ if (tone === "error") return theme.fg("error", text);
191
+ if (tone === "success") return theme.fg("success", text);
192
+ return theme.fg("dim", text);
193
+ }
194
+
195
+ function createFrameLine(
196
+ theme: ExtensionContext["ui"]["theme"],
197
+ line: string,
198
+ innerWidth: number,
199
+ ): string {
200
+ const pad = Math.max(0, innerWidth - visibleWidth(line));
201
+ return `${theme.fg("accent", "│ ")}${line}${" ".repeat(pad)}${theme.fg("accent", " │")}`;
202
+ }
203
+
204
+ function centerRenderedLines(lines: string[], width: number): string[] {
205
+ const renderedWidth = lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
206
+ const leftPad = Math.max(0, Math.floor((width - renderedWidth) / 2));
207
+ if (leftPad === 0) {
208
+ return lines;
209
+ }
210
+ const prefix = " ".repeat(leftPad);
211
+ return lines.map((line) => `${prefix}${line}`);
212
+ }
213
+
214
+ function renderCenteredDialog(
215
+ theme: ExtensionContext["ui"]["theme"],
216
+ width: number,
217
+ lines: string[],
218
+ maxInnerWidth = 64,
219
+ ): string[] {
220
+ const innerWidth = Math.max(20, Math.min(width - 4, maxInnerWidth));
221
+ const ellipsis = theme.fg("dim", "...");
222
+ const top = theme.fg("accent", `┌${"─".repeat(innerWidth + 2)}┐`);
223
+ const bottom = theme.fg("accent", `└${"─".repeat(innerWidth + 2)}┘`);
224
+ return centerRenderedLines(
225
+ [top, ...lines.map((line) => createFrameLine(theme, truncateToWidth(line, innerWidth, ellipsis), innerWidth)), bottom],
226
+ width,
227
+ );
228
+ }
229
+
230
+ function getEditorTheme(theme: ExtensionContext["ui"]["theme"]) {
231
+ return {
232
+ borderColor: (text: string) => theme.fg("accent", text),
233
+ selectList: {
234
+ selectedPrefix: (text: string) => theme.fg("accent", text),
235
+ selectedText: (text: string) => theme.bg("selectedBg", theme.fg("text", text)),
236
+ description: (text: string) => theme.fg("muted", text),
237
+ scrollInfo: (text: string) => theme.fg("dim", text),
238
+ noMatch: (text: string) => theme.fg("warning", text),
239
+ },
240
+ };
241
+ }
242
+
243
+ class ScrollableSkillPreview implements Component {
244
+ private scrollOffset = 0;
245
+ private lastInnerWidth = 1;
246
+ private lastContentLines: string[] = [];
247
+
248
+ constructor(
249
+ private skill: SkillEntry,
250
+ private readonly theme: ExtensionContext["ui"]["theme"],
251
+ private readonly getTerminalRows: () => number,
252
+ private readonly editable: boolean,
253
+ ) {}
254
+
255
+ setSkill(skill: SkillEntry): void {
256
+ this.skill = skill;
257
+ this.scrollOffset = 0;
258
+ this.lastContentLines = [];
259
+ }
260
+
261
+ invalidate(): void {}
262
+
263
+ private getInnerWidth(width: number): number {
264
+ return Math.max(1, width - 4);
265
+ }
266
+
267
+ private getMaxHeight(): number {
268
+ return Math.max(10, Math.floor(this.getTerminalRows() * 0.8));
269
+ }
270
+
271
+ private buildContentLines(innerWidth: number): string[] {
272
+ const content = new Container();
273
+ content.addChild(new Text(this.theme.fg("accent", this.theme.bold(this.skill.name)), 0, 0));
274
+ content.addChild(new Text(this.theme.fg("muted", `${getSkillLocationLabel(this.skill)} • ${getSkillLocation(this.skill)}`), 0, 0));
275
+ content.addChild(new Spacer(1));
276
+ content.addChild(new Text(this.theme.fg("muted", this.theme.bold("Metadata")), 0, 0));
277
+ content.addChild(new Text(this.theme.fg("dim", buildFrontmatterBlock(this.skill)), 0, 0));
278
+ content.addChild(new Spacer(1));
279
+ content.addChild(new Text(this.theme.fg("muted", this.theme.bold("Content")), 0, 0));
280
+ content.addChild(new Spacer(1));
281
+ content.addChild(new Markdown(this.skill.content, 0, 0, getMarkdownTheme()));
282
+ const lines = content.render(innerWidth);
283
+ this.lastInnerWidth = innerWidth;
284
+ this.lastContentLines = lines;
285
+ return lines;
286
+ }
287
+
288
+ private buildFooter(innerWidth: number, visibleHeight: number, totalLines: number): string {
289
+ const maxScroll = Math.max(0, totalLines - visibleHeight);
290
+ const scrollInfo = maxScroll > 0
291
+ ? ` • ${this.scrollOffset + 1}-${Math.min(totalLines, this.scrollOffset + visibleHeight)}/${totalLines}`
292
+ : "";
293
+ const editInfo = this.editable ? " • e edit • r rename" : "";
294
+ return truncateToWidth(
295
+ this.theme.fg("dim", `↑/↓ scroll • pgup/pgdn jump • home/end${editInfo} • esc back${scrollInfo}`),
296
+ innerWidth,
297
+ this.theme.fg("dim", "..."),
298
+ );
299
+ }
300
+
301
+ render(width: number): string[] {
302
+ const innerWidth = this.getInnerWidth(width);
303
+ const maxHeight = this.getMaxHeight();
304
+ const visibleHeight = Math.max(1, maxHeight - 3);
305
+ const contentLines = this.buildContentLines(innerWidth);
306
+ const maxScroll = Math.max(0, contentLines.length - visibleHeight);
307
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
308
+
309
+ const visibleLines = contentLines.slice(this.scrollOffset, this.scrollOffset + visibleHeight);
310
+ const top = this.theme.fg("accent", `┌${"─".repeat(innerWidth + 2)}┐`);
311
+ const bottom = this.theme.fg("accent", `└${"─".repeat(innerWidth + 2)}┘`);
312
+
313
+ return [
314
+ top,
315
+ ...visibleLines.map((line) => createFrameLine(this.theme, line, innerWidth)),
316
+ createFrameLine(this.theme, this.buildFooter(innerWidth, visibleHeight, contentLines.length), innerWidth),
317
+ bottom,
318
+ ];
319
+ }
320
+
321
+ handleInput(data: string): void {
322
+ const maxHeight = this.getMaxHeight();
323
+ const visibleHeight = Math.max(1, maxHeight - 3);
324
+ const totalLines = this.lastContentLines.length || this.buildContentLines(this.lastInnerWidth).length;
325
+ const maxScroll = Math.max(0, totalLines - visibleHeight);
326
+
327
+ if (matchesKey(data, Key.up)) {
328
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
329
+ return;
330
+ }
331
+ if (matchesKey(data, Key.down)) {
332
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
333
+ return;
334
+ }
335
+ if (matchesKey(data, Key.pageUp)) {
336
+ this.scrollOffset = Math.max(0, this.scrollOffset - visibleHeight);
337
+ return;
338
+ }
339
+ if (matchesKey(data, Key.pageDown)) {
340
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + visibleHeight);
341
+ return;
342
+ }
343
+ if (matchesKey(data, Key.home)) {
344
+ this.scrollOffset = 0;
345
+ return;
346
+ }
347
+ if (matchesKey(data, Key.end)) {
348
+ this.scrollOffset = maxScroll;
349
+ }
350
+ }
351
+ }
352
+
353
+ class SkillEditorView implements Component, Focusable {
354
+ private readonly editor: Editor;
355
+ private readonly initialText: string;
356
+ private readonly proxyTui: TUI;
357
+ private readonly realTui: TUI;
358
+ private virtualRows = 24;
359
+ private _focused = false;
360
+ private message: { text: string; tone: MessageTone } | undefined;
361
+
362
+ get focused(): boolean {
363
+ return this._focused;
364
+ }
365
+
366
+ set focused(value: boolean) {
367
+ this._focused = value;
368
+ this.editor.focused = value;
369
+ }
370
+
371
+ constructor(
372
+ private skill: SkillEntry,
373
+ private readonly theme: ExtensionContext["ui"]["theme"],
374
+ tui: TUI,
375
+ initialText: string,
376
+ private readonly onSave: (value: string) => void,
377
+ private readonly onCancel: () => void,
378
+ ) {
379
+ this.initialText = initialText;
380
+ this.realTui = tui;
381
+ const self = this;
382
+ this.proxyTui = {
383
+ requestRender: () => tui.requestRender(),
384
+ get terminal() {
385
+ return { ...tui.terminal, rows: Math.max(1, self.virtualRows) };
386
+ },
387
+ } as TUI;
388
+ this.editor = new Editor(this.proxyTui, getEditorTheme(theme), { autocompleteMaxVisible: 6 });
389
+ this.editor.setText(initialText);
390
+ }
391
+
392
+ setSkill(skill: SkillEntry): void {
393
+ this.skill = skill;
394
+ }
395
+
396
+ setMessage(text: string, tone: MessageTone): void {
397
+ this.message = { text, tone };
398
+ }
399
+
400
+ isDirty(): boolean {
401
+ return this.editor.getText() !== this.initialText;
402
+ }
403
+
404
+ invalidate(): void {
405
+ this.editor.invalidate();
406
+ }
407
+
408
+ private getTargetHeight(realRows: number): number {
409
+ return Math.max(10, Math.floor(realRows * 0.8));
410
+ }
411
+
412
+ private getRowsForVisibleEditorLines(targetVisibleLines: number): number {
413
+ let rows = 5;
414
+ while (Math.max(5, Math.floor(rows * 0.3)) < targetVisibleLines && rows < 1000) {
415
+ rows += 1;
416
+ }
417
+ return rows;
418
+ }
419
+
420
+ render(width: number): string[] {
421
+ const innerWidth = Math.max(20, width - 4);
422
+ const top = this.theme.fg("accent", `┌${"─".repeat(innerWidth + 2)}┐`);
423
+ const bottom = this.theme.fg("accent", `└${"─".repeat(innerWidth + 2)}┘`);
424
+ const lines: string[] = [
425
+ this.theme.fg("accent", this.theme.bold(`Edit ${this.skill.name}`)),
426
+ this.theme.fg("muted", getSkillLocation(this.skill)),
427
+ this.theme.fg("dim", `Name is immutable here: ${this.skill.name}`),
428
+ ];
429
+
430
+ if (this.message) {
431
+ lines.push("");
432
+ lines.push(getToneText(this.theme, this.message.tone, this.message.text));
433
+ }
434
+
435
+ const targetHeight = this.getTargetHeight(this.realTui.terminal.rows);
436
+ const targetInnerLines = Math.max(1, targetHeight - 2);
437
+ const staticLineCount = lines.length + 1 + 1 + 1;
438
+ const editorBlockLines = Math.max(7, targetInnerLines - staticLineCount);
439
+ const targetVisibleEditorLines = Math.max(5, editorBlockLines - 2);
440
+ this.virtualRows = this.getRowsForVisibleEditorLines(targetVisibleEditorLines);
441
+
442
+ lines.push("");
443
+ lines.push(...this.editor.render(innerWidth));
444
+ lines.push("");
445
+ lines.push(
446
+ truncateToWidth(
447
+ this.theme.fg("dim", "ctrl+s save • esc back"),
448
+ innerWidth,
449
+ this.theme.fg("dim", "..."),
450
+ ),
451
+ );
452
+
453
+ while (lines.length < targetInnerLines) {
454
+ lines.splice(Math.max(0, lines.length - 1), 0, "");
455
+ }
456
+
457
+ return [top, ...lines.slice(0, targetInnerLines).map((line) => createFrameLine(this.theme, line, innerWidth)), bottom];
458
+ }
459
+
460
+ handleInput(data: string): void {
461
+ if (matchesKey(data, Key.escape)) {
462
+ this.onCancel();
463
+ return;
464
+ }
465
+ if (matchesKey(data, Key.ctrl("s"))) {
466
+ this.onSave(this.editor.getText());
467
+ return;
468
+ }
469
+ if (this.message?.tone === "error") {
470
+ this.message = undefined;
471
+ }
472
+ this.editor.handleInput(data);
473
+ }
474
+ }
475
+
476
+ async function renameSkillEntry(ctx: ExtensionContext, skill: SkillEntry, entered: string): Promise<SkillEntry | null> {
477
+ if (!isDeletableSkill(skill)) {
478
+ ctx.ui.notify("Only your own project and global skills can be renamed", "warning");
479
+ return null;
480
+ }
481
+
482
+ const normalizedName = normalizeSkillName(entered);
483
+ if (!normalizedName) {
484
+ throw new Error("Name must contain letters, numbers, or hyphens");
485
+ }
486
+ if (normalizedName === skill.name) {
487
+ ctx.ui.notify("Skill name unchanged", "info");
488
+ return skill;
489
+ }
490
+
491
+ const currentDir = dirname(skill.path);
492
+ const parentDir = dirname(currentDir);
493
+ const targetDir = join(parentDir, normalizedName);
494
+ const targetPath = join(targetDir, "SKILL.md");
495
+ if (existsSync(targetDir) || existsSync(targetPath)) {
496
+ throw new Error(`Skill already exists: ${normalizedName}`);
497
+ }
498
+
499
+ const currentRaw = readFileSync(skill.path, "utf8");
500
+ const parsedCurrent = parseSkillDocument(currentRaw, skill.name);
501
+ const renamedFrontmatter = {
502
+ ...parsedCurrent.frontmatter,
503
+ name: normalizedName,
504
+ };
505
+ const updatedRaw = parsedCurrent.content
506
+ ? `${["---", ...Object.entries(renamedFrontmatter).flatMap(([key, value]) => formatYamlValue(key, value)), "---"].join("\n")}\n\n${parsedCurrent.content}\n`
507
+ : `${["---", ...Object.entries(renamedFrontmatter).flatMap(([key, value]) => formatYamlValue(key, value)), "---"].join("\n")}\n`;
508
+
509
+ await renamePath(currentDir, targetDir);
510
+ writeFileSync(targetPath, updatedRaw, "utf8");
511
+
512
+ const renamedSkill: SkillEntry = {
513
+ ...skill,
514
+ name: normalizedName,
515
+ path: targetPath,
516
+ frontmatter: renamedFrontmatter,
517
+ baseDir: targetDir,
518
+ };
519
+ ctx.ui.notify(`Renamed skill: ${skill.name} → ${normalizedName}`, "info");
520
+ return renamedSkill;
521
+ }
522
+
523
+ class SkillPreviewDialog implements Focusable {
524
+ private readonly editable: boolean;
525
+ private readonly preview: ScrollableSkillPreview;
526
+ private readonly renameInput = new Input();
527
+ private editorView: SkillEditorView | undefined;
528
+ private currentSkill: SkillEntry;
529
+ private mode: "preview" | "edit" | "rename" = "preview";
530
+ private _focused = false;
531
+ private discardConfirmOpen = false;
532
+ private renameError: string | undefined;
533
+
534
+ get focused(): boolean {
535
+ return this._focused;
536
+ }
537
+
538
+ set focused(value: boolean) {
539
+ this._focused = value;
540
+ this.renameInput.focused = value && this.mode === "rename";
541
+ if (this.editorView) {
542
+ this.editorView.focused = value;
543
+ }
544
+ }
545
+
546
+ constructor(
547
+ private readonly ctx: ExtensionContext,
548
+ skill: SkillEntry,
549
+ private readonly theme: ExtensionContext["ui"]["theme"],
550
+ private readonly tui: TUI,
551
+ private readonly close: () => void,
552
+ private readonly requestRender: () => void,
553
+ ) {
554
+ this.currentSkill = skill;
555
+ this.editable = isDeletableSkill(skill);
556
+ this.preview = new ScrollableSkillPreview(skill, theme, () => tui.terminal.rows, this.editable);
557
+ this.renameInput.onSubmit = (value) => {
558
+ void this.submitRename(value);
559
+ };
560
+ }
561
+
562
+ invalidate(): void {
563
+ this.preview.invalidate();
564
+ this.renameInput.invalidate();
565
+ this.editorView?.invalidate();
566
+ }
567
+
568
+ private renderDiscardConfirm(width: number): string[] {
569
+ return renderCenteredDialog(this.theme, width, [
570
+ this.theme.fg("accent", this.theme.bold("Discard changes?")),
571
+ "",
572
+ `Discard unsaved changes to ${this.currentSkill.name}?`,
573
+ "",
574
+ this.theme.fg("dim", "enter/y discard • esc/n keep editing"),
575
+ ]);
576
+ }
577
+
578
+ private renderRenameDialog(width: number): string[] {
579
+ const lines = [
580
+ this.theme.fg("accent", this.theme.bold("Rename skill")),
581
+ "",
582
+ this.theme.fg("dim", "Enter new skill name (lowercase letters, numbers, hyphens)"),
583
+ "",
584
+ ...this.renameInput.render(Math.max(20, Math.min(width - 4, 64))),
585
+ ];
586
+
587
+ if (this.renameError) {
588
+ lines.push("", getToneText(this.theme, "error", this.renameError));
589
+ }
590
+
591
+ lines.push("", this.theme.fg("dim", "enter save • esc cancel"));
592
+ return renderCenteredDialog(this.theme, width, lines);
593
+ }
594
+
595
+ render(width: number): string[] {
596
+ if (this.discardConfirmOpen) {
597
+ return this.renderDiscardConfirm(width);
598
+ }
599
+ if (this.mode === "rename") {
600
+ return this.renderRenameDialog(width);
601
+ }
602
+ return this.mode === "preview"
603
+ ? this.preview.render(width)
604
+ : this.editorView?.render(width) ?? this.preview.render(width);
605
+ }
606
+
607
+ handleInput(data: string): void {
608
+ if (this.discardConfirmOpen) {
609
+ if (matchesKey(data, Key.enter) || data === "y" || data === "Y") {
610
+ this.discardConfirmOpen = false;
611
+ this.closeEditor();
612
+ return;
613
+ }
614
+ if (matchesKey(data, Key.escape) || data === "n" || data === "N") {
615
+ this.discardConfirmOpen = false;
616
+ this.requestRender();
617
+ return;
618
+ }
619
+ return;
620
+ }
621
+
622
+ if (this.mode === "rename") {
623
+ if (matchesKey(data, Key.escape)) {
624
+ this.closeRenameDialog();
625
+ return;
626
+ }
627
+ if (this.renameError) {
628
+ this.renameError = undefined;
629
+ }
630
+ this.renameInput.handleInput(data);
631
+ return;
632
+ }
633
+
634
+ if (this.mode === "preview") {
635
+ if (this.editable && (data === "e" || data === "E")) {
636
+ this.openEditor();
637
+ return;
638
+ }
639
+ if (this.editable && (data === "r" || data === "R")) {
640
+ this.openRenameDialog();
641
+ return;
642
+ }
643
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.tab)) {
644
+ this.close();
645
+ return;
646
+ }
647
+ this.preview.handleInput(data);
648
+ return;
649
+ }
650
+
651
+ if (matchesKey(data, Key.escape)) {
652
+ void this.closeEditorMaybeConfirm();
653
+ return;
654
+ }
655
+
656
+ this.editorView?.handleInput(data);
657
+ }
658
+
659
+ private openEditor(): void {
660
+ this.discardConfirmOpen = false;
661
+ this.renameInput.focused = false;
662
+ const initialText = buildEditableSkillDocument(this.currentSkill, readSkillDocument(this.currentSkill));
663
+ this.editorView = new SkillEditorView(
664
+ this.currentSkill,
665
+ this.theme,
666
+ this.tui,
667
+ initialText,
668
+ (value) => this.saveEditedSkill(value),
669
+ () => this.closeEditor(),
670
+ );
671
+ this.editorView.focused = this._focused;
672
+ this.mode = "edit";
673
+ this.requestRender();
674
+ }
675
+
676
+ private closeEditor(): void {
677
+ this.discardConfirmOpen = false;
678
+ this.renameInput.focused = false;
679
+ this.mode = "preview";
680
+ this.editorView = undefined;
681
+ this.requestRender();
682
+ }
683
+
684
+ private async closeEditorMaybeConfirm(): Promise<void> {
685
+ if (!this.editorView || !this.editorView.isDirty()) {
686
+ this.closeEditor();
687
+ return;
688
+ }
689
+ this.discardConfirmOpen = true;
690
+ this.requestRender();
691
+ }
692
+
693
+ private openRenameDialog(): void {
694
+ this.renameError = undefined;
695
+ this.renameInput.setValue(this.currentSkill.name);
696
+ this.mode = "rename";
697
+ this.renameInput.focused = this._focused;
698
+ this.requestRender();
699
+ }
700
+
701
+ private closeRenameDialog(): void {
702
+ this.renameError = undefined;
703
+ this.renameInput.focused = false;
704
+ this.mode = "preview";
705
+ this.requestRender();
706
+ }
707
+
708
+ private async submitRename(value: string): Promise<void> {
709
+ try {
710
+ const renamed = await renameSkillEntry(this.ctx, this.currentSkill, value);
711
+ if (!renamed) {
712
+ this.closeRenameDialog();
713
+ return;
714
+ }
715
+ this.currentSkill = renamed;
716
+ this.preview.setSkill(renamed);
717
+ this.editorView?.setSkill(renamed);
718
+ this.closeRenameDialog();
719
+ } catch (error) {
720
+ this.renameError = error instanceof Error ? error.message : "Failed to rename skill";
721
+ this.requestRender();
722
+ }
723
+ }
724
+
725
+ private saveEditedSkill(raw: string): void {
726
+ try {
727
+ const parsed = parseEditableSkillDocument(raw, this.currentSkill.name);
728
+ writeFileSync(this.currentSkill.path, parsed.raw, "utf8");
729
+ this.currentSkill = toUpdatedSkill(this.currentSkill, parsed);
730
+ this.preview.setSkill(this.currentSkill);
731
+ this.editorView?.setSkill(this.currentSkill);
732
+ this.ctx.ui.notify(`Updated skill: ${this.currentSkill.name}`, "info");
733
+ this.closeEditor();
734
+ } catch (error) {
735
+ this.editorView?.setMessage(error instanceof Error ? error.message : "Failed to save skill", "error");
736
+ this.requestRender();
737
+ }
738
+ }
739
+ }
740
+
741
+ export async function showSkillPreview(ctx: ExtensionContext, skill: SkillEntry): Promise<void> {
742
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
743
+ const close = () => {
744
+ done();
745
+ };
746
+ const dialog = new SkillPreviewDialog(ctx, skill, theme, tui, close, () => tui.requestRender());
747
+
748
+ return {
749
+ get focused() {
750
+ return dialog.focused;
751
+ },
752
+ set focused(value: boolean) {
753
+ dialog.focused = value;
754
+ },
755
+ render(width: number) {
756
+ return dialog.render(width);
757
+ },
758
+ invalidate() {
759
+ dialog.invalidate();
760
+ },
761
+ handleInput(data: string) {
762
+ dialog.handleInput(data);
763
+ tui.requestRender();
764
+ },
765
+ };
766
+ }, { overlay: true, overlayOptions: { width: "80%", maxHeight: "85%", anchor: "center" } });
767
+ }