@zle13/pi-prompt-snippets 0.2.0 → 0.3.0

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 CHANGED
@@ -30,8 +30,9 @@ Then restart Pi or run `/reload` in your active session.
30
30
  - `↑` / `↓` (or custom selection keybindings like `Ctrl+P` / `Ctrl+N`) to navigate items.
31
31
  - `Space` to toggle snippet active / inactive.
32
32
  - `Tab` to preview the highlighted snippet (shows name, placement, order, source origin, filename, and prompt body). Press `Tab` or `Esc` (or `Ctrl+G` / `Ctrl+C`) to return to the list with cursor position preserved.
33
+ - `Ctrl+E` to manage snippets: create, edit, duplicate, delete your files, hide bundled snippets, or restore hiding files. Sources (`bundled`, `global`, `project`) appear beside each snippet.
33
34
  - `Enter` to apply your selection.
34
- - `Esc` to cancel and discard changes.
35
+ - `Esc` to cancel and discard **toggle changes only**. Explicitly saved snippet edits remain saved.
35
36
  - **Keybinding Integration:** Respects custom `tui.select.*` actions from `~/.pi/agent/keybindings.json` (such as `tui.select.up`, `tui.select.down`, `tui.select.pageUp`, `tui.select.pageDown`, `tui.select.confirm`, and `tui.select.cancel`).
36
37
  - **Active Snippet Indicator:** Active snippets appear as a widget above the editor:
37
38
  - `↑ prepend: ...` (accent color) — inserted before your message.
@@ -71,6 +72,18 @@ Files are re-scanned whenever the menu opens or a message is sent, so edits take
71
72
 
72
73
  ## Customizing, Overriding & Disabling Snippets
73
74
 
75
+ ### Manage without leaving Pi
76
+
77
+ Press **Alt+S → Ctrl+E**. Management acts on the highlighted snippet; **New snippet** and **Restore hidden snippet** are available even when the list is empty.
78
+
79
+ - **New / Duplicate:** choose Global or This project (trusted projects only). The last chosen location is remembered for this extension runtime. Set a unique `.md` filename; existing files are never silently overwritten.
80
+ - **Edit:** opens a details screen. Select name, description, placement, order, or prompt body to edit that field. Each field shows its heading followed by a separate, dimmed **Tip:** line explaining what to enter (including placement and prompt body). **Order** is a whole number: lower values appear first within the prepend or append group (10 before 20; ties sort by name), not a model instruction priority. Existing text fields are prefilled; new snippets start with an empty, required name. The body uses Pi's multiline editor. Returning from a field only updates the draft; choose **Save snippet** to write it, or **Discard changes** to leave the file untouched.
81
+ - **Bundled edits:** choose a location for an override with the same filename. Bundled files are never modified.
82
+ - **Delete:** confirms the exact user-file path and warns about lower-priority versions becoming visible. Deleting an override is not the same as hiding its bundled/global version.
83
+ - **Hide:** bundled snippets can be hidden globally or for this project. **Restore hidden snippet** removes a confirmed bodyless hiding file to reveal the lower-priority version. Snippets disabled in `prompt-snippets.json`, or disabled files containing a prompt body, remain managed manually.
84
+
85
+ Saving or deleting returns to the toggle list with pending selections and cursor position preserved where possible. Changes take effect immediately, even if you later cancel the toggle menu. Files changed externally while a draft is open are not overwritten; discard and reopen the draft to refresh. Symlinked snippet files/directories are read-only to management; edit those manually.
86
+
74
87
  ### 1. Add Your Own Snippets
75
88
 
76
89
  Create any `.md` file in `~/.pi/agent/snippets/` (for all projects) or `.pi/snippets/` (for the current project):
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Storage helpers for writable prompt snippets (global and project scopes).
3
+ *
4
+ * Bundled snippets are never written: mutations refuse symlinks (directories
5
+ * and files) and refuse any target resolving inside the bundled snippets
6
+ * directory. `expected` is the exact prior raw content from `readSnapshot`
7
+ * (`null` = create only); a mismatched snapshot fails instead of clobbering
8
+ * concurrent changes. Every check/write window runs inside
9
+ * `withFileMutationQueue`, which serializes operations on the same file.
10
+ */
11
+
12
+ import { lstatSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
13
+ import type { Stats } from "node:fs";
14
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
15
+ import { CONFIG_DIR_NAME, getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
16
+ import { parseSnippet, resolveBundledSnippetsDir } from "./loader.js";
17
+ import type { LoadSnippetsOptions, SnippetPlacement } from "./types.js";
18
+
19
+ /** Scopes that may be written. Bundled snippets are read-only. */
20
+ export type WritableScope = "global" | "project";
21
+
22
+ /** A complete snippet definition as produced by editor UIs. */
23
+ export interface SnippetDraft {
24
+ /** Filename identifier, must be a safe basename ending in .md */
25
+ id: string;
26
+ /** Display name (single line, non-empty) */
27
+ name: string;
28
+ /** Short description shown in menu (single line, may be empty) */
29
+ description: string;
30
+ /** Placement before or after user message */
31
+ placement: SnippetPlacement;
32
+ /** Sort order within placement group (safe integer) */
33
+ order: number;
34
+ /** Prompt content inserted into message (non-empty) */
35
+ body: string;
36
+ }
37
+
38
+ /** Tombstone frontmatter written by hideSnippet; the loader parses it as disabled. */
39
+ const TOMBSTONE_RAW = "---\ndisabled: true\n---\n";
40
+
41
+ const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
42
+
43
+ interface NormalizedDraft {
44
+ id: string;
45
+ name: string;
46
+ description: string;
47
+ placement: SnippetPlacement;
48
+ order: number;
49
+ body: string;
50
+ }
51
+
52
+ function isEnoent(error: unknown): boolean {
53
+ return typeof error === "object" && error !== null && (error as NodeJS.ErrnoException).code === "ENOENT";
54
+ }
55
+
56
+ /** Resolve `<agentDir>/snippets` or `<cwd>/.pi/snippets`; project needs a trusted cwd. */
57
+ export function snippetDirectory(options: LoadSnippetsOptions, scope: WritableScope): string {
58
+ if (scope !== "global" && scope !== "project") {
59
+ throw new Error(`Unknown writable snippet scope: ${String(scope)}`);
60
+ }
61
+ if (scope === "global") {
62
+ let agentDir: string;
63
+ try {
64
+ agentDir = options.agentDir ?? getAgentDir();
65
+ } catch {
66
+ agentDir = options.agentDir ?? join(process.env.HOME || "", ".pi", "agent");
67
+ }
68
+ return join(agentDir, "snippets");
69
+ }
70
+ if (!options.cwd) {
71
+ throw new Error("Project snippets require a working directory (options.cwd)");
72
+ }
73
+ if (options.isProjectTrusted === false) {
74
+ throw new Error("Project snippets are unavailable because this project is not trusted");
75
+ }
76
+ return join(options.cwd, CONFIG_DIR_NAME || ".pi", "snippets");
77
+ }
78
+
79
+ /** Exact raw content of a snippet file, or `null` only for ENOENT. Refuses symlinks. */
80
+ export function readSnapshot(path: string): string | null {
81
+ let stats: Stats;
82
+ try {
83
+ stats = lstatSync(path);
84
+ } catch (error) {
85
+ if (isEnoent(error)) return null;
86
+ throw error;
87
+ }
88
+ if (stats.isSymbolicLink()) {
89
+ throw new Error(`Refusing to read a symlink: ${path}`);
90
+ }
91
+ if (!stats.isFile()) {
92
+ throw new Error(`Snippet path is not a regular file: ${path}`);
93
+ }
94
+ return readFileSync(path, "utf-8");
95
+ }
96
+
97
+ /** Create (`expected: null`) or replace (`expected: prior raw`) a snippet file. */
98
+ export async function saveSnippet(
99
+ options: LoadSnippetsOptions,
100
+ scope: WritableScope,
101
+ draft: SnippetDraft,
102
+ expected: string | null,
103
+ ): Promise<void> {
104
+ if (expected !== null && typeof expected !== "string") {
105
+ throw new Error("expected must be null (create only) or the exact prior raw file content");
106
+ }
107
+ const normalized = normalizeDraft(draft);
108
+ const raw = serializeSnippet(normalized);
109
+ verifyRoundTrip(normalized, raw, scope);
110
+ const dir = snippetDirectory(options, scope);
111
+ const filePath = join(dir, normalized.id);
112
+ await withFileMutationQueue(filePath, async () => {
113
+ assertWritableSnippetDirectory(dir, options, true);
114
+ const current = readSnapshot(filePath);
115
+ if (expected === null) {
116
+ if (current !== null) {
117
+ throw new Error(
118
+ `Snippet already exists: ${normalized.id}. Pass its current content as expected to replace it.`,
119
+ );
120
+ }
121
+ } else if (current === null) {
122
+ throw new Error(`Snippet was removed before this save (stale expected content): ${normalized.id}`);
123
+ } else if (current !== expected) {
124
+ throw new Error(`Snippet changed since it was read (stale expected content): ${normalized.id}`);
125
+ }
126
+ writeFileSync(filePath, raw, "utf-8");
127
+ });
128
+ }
129
+
130
+ /** Delete a snippet file; the exact prior raw content is required. */
131
+ export async function deleteSnippet(
132
+ options: LoadSnippetsOptions,
133
+ scope: WritableScope,
134
+ id: string,
135
+ expected: string,
136
+ ): Promise<void> {
137
+ if (typeof expected !== "string") {
138
+ throw new Error("deleteSnippet requires the exact prior raw file content as expected");
139
+ }
140
+ const validatedId = validateSnippetId(id);
141
+ const dir = snippetDirectory(options, scope);
142
+ const filePath = join(dir, validatedId);
143
+ await withFileMutationQueue(filePath, async () => {
144
+ assertWritableSnippetDirectory(dir, options, false);
145
+ const current = readSnapshot(filePath);
146
+ if (current === null) {
147
+ throw new Error(`Snippet not found: ${validatedId}`);
148
+ }
149
+ if (current !== expected) {
150
+ throw new Error(`Snippet changed since it was read (stale expected content): ${validatedId}`);
151
+ }
152
+ unlinkSync(filePath);
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Replace a snippet file with a `disabled: true` tombstone in a writable
158
+ * scope. Hiding a bundled snippet writes the tombstone under the bundled
159
+ * filename in the global/project scope, which makes the loader skip it; the
160
+ * bundled directory is never touched. Reveal with `deleteSnippet` or
161
+ * `saveSnippet` using the tombstone content as `expected`.
162
+ */
163
+ export async function hideSnippet(
164
+ options: LoadSnippetsOptions,
165
+ scope: WritableScope,
166
+ id: string,
167
+ expected: string | null,
168
+ ): Promise<void> {
169
+ if (expected !== null && typeof expected !== "string") {
170
+ throw new Error("expected must be null (create only) or the exact prior raw file content");
171
+ }
172
+ const validatedId = validateSnippetId(id);
173
+ const dir = snippetDirectory(options, scope);
174
+ const filePath = join(dir, validatedId);
175
+ await withFileMutationQueue(filePath, async () => {
176
+ assertWritableSnippetDirectory(dir, options, true);
177
+ const current = readSnapshot(filePath);
178
+ if (expected === null) {
179
+ if (current !== null) {
180
+ throw new Error(
181
+ `File already exists: ${validatedId}. Pass its current content as expected to replace it with a disable tombstone.`,
182
+ );
183
+ }
184
+ } else if (current === null) {
185
+ throw new Error(`Snippet was removed before this change (stale expected content): ${validatedId}`);
186
+ } else if (current !== expected) {
187
+ throw new Error(`Snippet changed since it was read (stale expected content): ${validatedId}`);
188
+ }
189
+ writeFileSync(filePath, TOMBSTONE_RAW, "utf-8");
190
+ });
191
+ }
192
+
193
+ function validateSnippetId(id: unknown): string {
194
+ if (typeof id !== "string" || id.length === 0) {
195
+ throw new Error("Snippet filename must be a non-empty string");
196
+ }
197
+ if (id.length > 200) {
198
+ throw new Error(`Snippet filename is too long (max 200 characters): ${id}`);
199
+ }
200
+ if (!/\.md$/i.test(id)) {
201
+ throw new Error(`Snippet filename must end with .md: ${id}`);
202
+ }
203
+ if (id.startsWith(".")) {
204
+ throw new Error(`Snippet filename must not start with a dot: ${id}`);
205
+ }
206
+ if (/[/\\:]/.test(id)) {
207
+ throw new Error(`Snippet filename must be a plain basename without path separators: ${id}`);
208
+ }
209
+ if (/[\0-\x1f]/.test(id)) {
210
+ throw new Error(`Snippet filename must not contain control characters: ${JSON.stringify(id)}`);
211
+ }
212
+ return id;
213
+ }
214
+
215
+ function singleLineField(value: unknown, field: string, required: boolean): string {
216
+ if (typeof value !== "string") {
217
+ throw new Error(`Snippet ${field} must be a string`);
218
+ }
219
+ if (LINE_TERMINATOR.test(value)) {
220
+ throw new Error(`Snippet ${field} must be a single line`);
221
+ }
222
+ const trimmed = value.trim();
223
+ if (required && !trimmed) {
224
+ throw new Error(`Snippet ${field} must be a non-empty string`);
225
+ }
226
+ return trimmed;
227
+ }
228
+
229
+ function normalizeDraft(draft: SnippetDraft): NormalizedDraft {
230
+ if (!draft || typeof draft !== "object") {
231
+ throw new Error("Snippet draft must be an object");
232
+ }
233
+ const id = validateSnippetId(draft.id);
234
+ const name = singleLineField(draft.name, "name", true);
235
+ const description = singleLineField(draft.description, "description", false);
236
+ if (draft.placement !== "prepend" && draft.placement !== "append") {
237
+ throw new Error(`Snippet placement must be "prepend" or "append", got: ${String(draft.placement)}`);
238
+ }
239
+ if (!Number.isSafeInteger(draft.order)) {
240
+ throw new Error(`Snippet order must be a safe integer, got: ${String(draft.order)}`);
241
+ }
242
+ const body = typeof draft.body === "string" ? draft.body.trim() : "";
243
+ if (!body) {
244
+ throw new Error("Snippet body must be a non-empty string");
245
+ }
246
+ return { id, name, description, placement: draft.placement, order: draft.order, body };
247
+ }
248
+
249
+ /**
250
+ * Encode a frontmatter value so the loader's `key: value` parser (which trims
251
+ * the value and strips one leading and one trailing quote) returns it
252
+ * unchanged: values that start or end with a quote are wrapped in an extra
253
+ * pair of double quotes.
254
+ */
255
+ function encodeFrontmatterValue(value: string): string {
256
+ if (value === "") return "";
257
+ if (/^["']/.test(value) || /["']$/.test(value)) return `"${value}"`;
258
+ return value;
259
+ }
260
+
261
+ function serializeSnippet(draft: NormalizedDraft): string {
262
+ return [
263
+ "---",
264
+ `name: ${encodeFrontmatterValue(draft.name)}`,
265
+ `description: ${encodeFrontmatterValue(draft.description)}`,
266
+ `placement: ${draft.placement}`,
267
+ `order: ${draft.order}`,
268
+ "---",
269
+ draft.body,
270
+ "",
271
+ ].join("\n");
272
+ }
273
+
274
+ /** Fail unless the serialized raw parses back to exactly the drafted fields. */
275
+ function verifyRoundTrip(draft: NormalizedDraft, raw: string, scope: WritableScope): void {
276
+ const { snippet, disabled } = parseSnippet(draft.id, raw, scope, "");
277
+ const parsed = disabled ? null : snippet;
278
+ if (!parsed) {
279
+ throw new Error(`Serialized snippet ${draft.id} does not parse as an enabled snippet`);
280
+ }
281
+ if (
282
+ parsed.name !== draft.name ||
283
+ parsed.description !== draft.description ||
284
+ parsed.placement !== draft.placement ||
285
+ parsed.order !== draft.order ||
286
+ parsed.body !== draft.body
287
+ ) {
288
+ throw new Error(`Serialized frontmatter for ${draft.id} would not round-trip through the snippet loader`);
289
+ }
290
+ }
291
+
292
+ function isSameOrInside(candidate: string, parent: string): boolean {
293
+ if (candidate === parent) return true;
294
+ const rel = relative(parent, candidate);
295
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
296
+ }
297
+
298
+ /**
299
+ * Ensure the snippet directory is writable: refuse symlink directories and
300
+ * non-directories, and refuse any target resolving inside the bundled
301
+ * snippets directory. Checks run against the deepest existing ancestor so
302
+ * they happen before any directory is created.
303
+ */
304
+ function assertWritableSnippetDirectory(dir: string, options: LoadSnippetsOptions, create: boolean): void {
305
+ let probe = dir;
306
+ let stats: Stats | undefined;
307
+ for (;;) {
308
+ try {
309
+ stats = lstatSync(probe);
310
+ break;
311
+ } catch (error) {
312
+ if (!isEnoent(error)) throw error;
313
+ const parent = dirname(probe);
314
+ if (parent === probe) break;
315
+ probe = parent;
316
+ }
317
+ }
318
+
319
+ if (stats && probe === dir && stats.isSymbolicLink()) {
320
+ throw new Error(`Refusing to write snippets through a symlink directory: ${dir}`);
321
+ }
322
+ if (stats && probe === dir && !stats.isDirectory()) {
323
+ throw new Error(`Snippet directory path is not a directory: ${dir}`);
324
+ }
325
+
326
+ const bundledDir = resolveBundledSnippetsDir(options.bundledDir);
327
+ if (stats && bundledDir) {
328
+ const bundledReal = realpathSync(bundledDir);
329
+ const resolved = realpathSync(probe);
330
+ if (isSameOrInside(resolved, bundledReal)) {
331
+ throw new Error(`Refusing to modify bundled snippets via ${dir}`);
332
+ }
333
+ }
334
+
335
+ if (create && (!stats || probe !== dir)) {
336
+ mkdirSync(dir, { recursive: true });
337
+ }
338
+ }
@@ -0,0 +1,209 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { Input, Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
5
+ import { loadSnippets, parseSnippet } from "./loader.js";
6
+ import {
7
+ deleteSnippet, hideSnippet, readSnapshot, saveSnippet, snippetDirectory,
8
+ type SnippetDraft, type WritableScope,
9
+ } from "./management-store.js";
10
+ import type { LoadSnippetsOptions, Snippet } from "./types.js";
11
+
12
+ const FIELD_HELP = {
13
+ name: "Display name in the snippet menu. Use a short, recognizable label, e.g. Concise answers.",
14
+ description: "Optional subtitle explaining when to use this snippet. Not sent to the model.",
15
+ placement: "Prepend inserts instructions before your message; append inserts them after your message.",
16
+ order: "Whole number controlling sequence within the prepend or append group. Lower numbers appear first: 10 before 20. Ties sort by name. This is not instruction priority.",
17
+ body: "The instructions inserted into your message when this snippet is active. Multiple lines are allowed.",
18
+ id: "Unique .md filename, e.g. concise-answers.md. This is the snippet ID; matching filenames override lower-priority sources. No folders.",
19
+ };
20
+
21
+ /** A prefilled single-line input, with the host's confirm/cancel bindings. */
22
+ async function editText(ctx: ExtensionContext, title: string, value: string, help: string): Promise<string | undefined> {
23
+ return ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
24
+ const input = new Input();
25
+ input.setValue(value);
26
+ let finished = false;
27
+ const finish = (result: string | undefined) => {
28
+ if (finished) return;
29
+ finished = true;
30
+ done(result);
31
+ };
32
+ input.onSubmit = finish;
33
+ input.onEscape = () => finish(undefined);
34
+ return {
35
+ get focused() { return input.focused; },
36
+ set focused(value: boolean) { input.focused = value; },
37
+ render(width: number) {
38
+ return [
39
+ truncateToWidth(theme.fg("accent", title), width),
40
+ ...wrapTextWithAnsi(theme.fg("dim", `Tip: ${help}`), width),
41
+ "",
42
+ ...input.render(width),
43
+ truncateToWidth(theme.fg("dim", "Enter accept field • Esc back • Save in the details screen to persist"), width),
44
+ ];
45
+ },
46
+ invalidate() { input.invalidate(); },
47
+ handleInput(data: string) {
48
+ if (finished) return;
49
+ if (keybindings.matches(data, "tui.select.cancel") || matchesKey(data, Key.escape)) finish(undefined);
50
+ else if (keybindings.matches(data, "tui.select.confirm")) finish(input.getValue());
51
+ else input.handleInput(data);
52
+ tui.requestRender();
53
+ },
54
+ };
55
+ });
56
+ }
57
+
58
+ export function createSnippetManager() {
59
+ let lastScope: WritableScope = "global";
60
+
61
+ async function chooseScope(ctx: ExtensionContext, options: LoadSnippetsOptions): Promise<WritableScope | undefined> {
62
+ const scopes: WritableScope[] = options.isProjectTrusted === false ? ["global"] : [lastScope, lastScope === "global" ? "project" : "global"];
63
+ const labels = scopes.map((scope) => `${scope === "global" ? "Global" : "This project"} — ${snippetDirectory(options, scope)}`);
64
+ const choice = await ctx.ui.select("Save location", labels);
65
+ if (choice === undefined) return;
66
+ lastScope = scopes[labels.indexOf(choice)];
67
+ return lastScope;
68
+ }
69
+
70
+ async function editDraft(ctx: ExtensionContext, options: LoadSnippetsOptions, original?: Snippet, duplicate = false) {
71
+ const scope = original && !duplicate && original.source !== "bundled" ? original.source : await chooseScope(ctx, options);
72
+ if (!scope) return;
73
+ const isEdit = original && !duplicate && original.source !== "bundled";
74
+ let expected: string | null = null;
75
+ let source = original;
76
+ if (isEdit) {
77
+ const path = join(snippetDirectory(options, scope), original.id);
78
+ expected = readSnapshot(path);
79
+ if (expected === null) throw new Error("Snippet was removed. Reopen management to refresh.");
80
+ source = parseSnippet(original.id, expected, scope, path).snippet ?? undefined;
81
+ if (!source) throw new Error("Snippet is now hidden or invalid. Reopen management to refresh.");
82
+ }
83
+ const draft: SnippetDraft = {
84
+ id: source ? (duplicate ? source.id.replace(/\.md$/i, "-copy.md") : source.id) : "new-snippet.md",
85
+ name: source ? `${source.name}${duplicate ? " (copy)" : ""}` : "",
86
+ description: source?.description ?? "",
87
+ placement: source?.placement ?? "append",
88
+ order: source?.order ?? 10,
89
+ body: source?.body ?? "",
90
+ };
91
+ const initial = JSON.stringify(draft);
92
+ while (true) {
93
+ const fields = [
94
+ `Name: ${draft.name || "(required)"}`,
95
+ `Description: ${draft.description || "(none)"}`,
96
+ `Placement: ${draft.placement}`,
97
+ `Order: ${draft.order}`,
98
+ `Prompt body: ${draft.body.split("\n")[0] || "(empty)"}`,
99
+ ...(!original || duplicate ? [`Filename: ${draft.id}`] : []),
100
+ "Save snippet", "Discard changes",
101
+ ];
102
+ const path = join(snippetDirectory(options, scope), draft.id);
103
+ const choice = await ctx.ui.select(`Snippet details [${scope}] — ${path}`, fields);
104
+ const field = fields.indexOf(choice ?? "");
105
+ if (choice === undefined || choice === "Discard changes") {
106
+ if (JSON.stringify(draft) === initial || await ctx.ui.confirm("Discard unsaved snippet?", "No file changes have been saved.")) return;
107
+ continue;
108
+ }
109
+ if (choice === "Save snippet") {
110
+ try {
111
+ await saveSnippet(options, scope, draft, expected);
112
+ const effective = loadSnippets(options).find((s) => s.id === draft.id);
113
+ ctx.ui.notify(`Saved ${path}${effective?.source !== scope ? " (hidden by configuration or a higher-priority snippet)" : ""}`, "info");
114
+ return;
115
+ } catch (error) {
116
+ ctx.ui.notify(String(error), "error");
117
+ continue;
118
+ }
119
+ }
120
+ if (field === 2) {
121
+ const placement = await ctx.ui.select(`Edit placement\n${ctx.ui.theme.fg("dim", `Tip: ${FIELD_HELP.placement}`)}`, ["prepend", "append"]);
122
+ if (placement === "prepend" || placement === "append") draft.placement = placement;
123
+ } else if (field === 4) {
124
+ const body = await ctx.ui.editor(`Edit prompt body\n${ctx.ui.theme.fg("dim", `Tip: ${FIELD_HELP.body} Return to details, then Save snippet.`)}`, draft.body);
125
+ if (body !== undefined) draft.body = body;
126
+ } else if (field >= 0) {
127
+ const key = (["name", "description", "", "order", "", "id"] as const)[field];
128
+ if (!key) continue;
129
+ const value = await editText(ctx, `Edit ${key === "id" ? "filename" : key}`, String(draft[key]), FIELD_HELP[key]);
130
+ if (value === undefined) continue;
131
+ if (key === "order") {
132
+ const order = value.trim() ? Number(value) : NaN;
133
+ if (Number.isSafeInteger(order)) draft.order = order;
134
+ else ctx.ui.notify("Order must be a whole number", "warning");
135
+ } else draft[key] = value;
136
+ }
137
+ }
138
+ }
139
+
140
+ async function restoreHidden(ctx: ExtensionContext, options: LoadSnippetsOptions) {
141
+ const hidden: { scope: WritableScope; id: string; raw: string; label: string }[] = [];
142
+ for (const scope of ["global", "project"] as const) {
143
+ if (scope === "project" && options.isProjectTrusted === false) continue;
144
+ const dir = snippetDirectory(options, scope);
145
+ let files;
146
+ try { files = readdirSync(dir, { withFileTypes: true }); } catch (error) {
147
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
148
+ throw error;
149
+ }
150
+ for (const entry of files.sort((a, b) => a.name.localeCompare(b.name))) {
151
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md")) continue;
152
+ const id = entry.name;
153
+ let raw: string | null;
154
+ try { raw = readSnapshot(join(dir, id)); } catch {
155
+ // An unrelated unreadable or concurrently replaced file must not block restoration.
156
+ continue;
157
+ }
158
+ // Only offer tombstones, never delete a disabled user snippet's prompt body.
159
+ const body = raw?.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/)?.[1];
160
+ if (raw !== null && body?.trim() === "" && parseSnippet(id, raw, scope, join(dir, id)).disabled) {
161
+ hidden.push({ scope, id, raw, label: `${id} [${scope}]` });
162
+ }
163
+ }
164
+ }
165
+ if (!hidden.length) {
166
+ ctx.ui.notify("No file-hidden snippets. Snippets disabled in prompt-snippets.json must be restored there.", "info");
167
+ return;
168
+ }
169
+ const choice = await ctx.ui.select("Restore hidden snippet", hidden.map((h) => h.label));
170
+ const item = hidden.find((h) => h.label === choice);
171
+ if (!item || !await ctx.ui.confirm("Remove hiding file?", `${join(snippetDirectory(options, item.scope), item.id)}\nRemoves this file to reveal any lower-priority snippet. Configuration may still hide it.`)) return;
172
+ await deleteSnippet(options, item.scope, item.id, item.raw);
173
+ ctx.ui.notify("Hiding file removed", "info");
174
+ }
175
+
176
+ return async function manageSnippet(ctx: ExtensionContext, options: LoadSnippetsOptions, selectedId?: string) {
177
+ try {
178
+ const selected = loadSnippets(options).find((s) => s.id === selectedId);
179
+ const actions = ["New snippet", ...(selected ? ["Edit snippet", "Duplicate snippet", selected.source === "bundled" ? "Hide snippet" : "Delete snippet"] : []), "Restore hidden snippet", "Back to snippets"];
180
+ const action = await ctx.ui.select(selected ? `Manage: ${selected.name} [${selected.source}] — ${selected.filePath}` : "Manage snippets", actions);
181
+ if (action === "New snippet") await editDraft(ctx, options);
182
+ else if (action === "Edit snippet" && selected) await editDraft(ctx, options, selected);
183
+ else if (action === "Duplicate snippet" && selected) await editDraft(ctx, options, selected, true);
184
+ else if (action === "Restore hidden snippet") await restoreHidden(ctx, options);
185
+ else if (action === "Hide snippet" && selected) {
186
+ const scope = await chooseScope(ctx, options);
187
+ if (!scope) return;
188
+ const path = join(snippetDirectory(options, scope), selected.id);
189
+ if (!await ctx.ui.confirm("Hide bundled snippet?", `Create ${path}\nThe bundled file is never modified. Use Restore hidden snippet to undo.`)) return;
190
+ await hideSnippet(options, scope, selected.id, null);
191
+ ctx.ui.notify(`Hidden ${selected.name} [${scope}]`, "info");
192
+ } else if (action === "Delete snippet" && selected && selected.source !== "bundled") {
193
+ const path = join(snippetDirectory(options, selected.source), selected.id);
194
+ const expected = readSnapshot(path);
195
+ if (expected === null) throw new Error("Snippet was already removed");
196
+ // Resolve a preview without the selected layer; configurations still apply.
197
+ const lower = selected.source === "project"
198
+ ? loadSnippets({ ...options, isProjectTrusted: false }).find((s) => s.id === selected.id)
199
+ : undefined;
200
+ const reveal = lower ? `Restore ${lower.source} version: ${lower.name}.` : "Any lower-priority version will become visible unless disabled by configuration.";
201
+ if (!await ctx.ui.confirm("Delete snippet file?", `${path}\n${reveal}\nThis cannot be undone.`)) return;
202
+ await deleteSnippet(options, selected.source, selected.id, expected);
203
+ ctx.ui.notify(`Deleted ${path}`, "info");
204
+ }
205
+ } catch (error) {
206
+ ctx.ui.notify(String(error), "error");
207
+ }
208
+ };
209
+ }
@@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url";
15
15
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
16
  import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
17
17
  import { loadSnippets } from "./loader.js";
18
+ import { createSnippetManager } from "./management-ui.js";
18
19
  import type { LoadSnippetsOptions, Snippet } from "./types.js";
19
20
 
20
21
  const WIDGET_ID = "prompt-snippets";
@@ -26,6 +27,8 @@ export function createPromptSnippetsExtension(extensionOptions: LoadSnippetsOpti
26
27
  return function promptSnippetsExtension(pi: ExtensionAPI) {
27
28
  let snippets: Snippet[] = [];
28
29
  let enabled = new Set<string>();
30
+ const manageSnippet = createSnippetManager();
31
+ let menuOpen = false;
29
32
 
30
33
  function getSnippets(ctx: ExtensionContext): Snippet[] {
31
34
  const isTrusted = typeof ctx.isProjectTrusted === "function" ? ctx.isProjectTrusted() : true;
@@ -59,220 +62,248 @@ export function createPromptSnippetsExtension(extensionOptions: LoadSnippetsOpti
59
62
  }
60
63
 
61
64
  async function openMenu(ctx: ExtensionContext) {
62
- if (ctx.mode !== "tui") {
63
- ctx.ui.notify("Snippet menu requires interactive mode", "warning");
65
+ if (!ctx.hasUI || ctx.mode !== "tui") {
66
+ if (ctx.hasUI) ctx.ui.notify("Snippet menu requires interactive mode", "warning");
64
67
  return;
65
68
  }
66
69
 
67
- snippets = getSnippets(ctx);
68
- enabled = new Set([...enabled].filter((id) => snippets.some((s) => s.id === id)));
69
-
70
- if (snippets.length === 0) {
71
- ctx.ui.notify("No snippets found in bundled, global, or project snippet directories", "warning");
72
- updateWidget(ctx);
73
- return;
74
- }
75
-
76
- const working = new Set(enabled);
77
-
78
- const confirmed = await ctx.ui.custom<boolean>((tui, theme, keybindings, done) => {
79
- const prepends = snippets.filter((s) => s.placement === "prepend");
80
- const appends = snippets.filter((s) => s.placement === "append");
81
- const items = [...prepends, ...appends];
82
-
83
- let mode: "list" | "preview" = "list";
70
+ if (menuOpen) return;
71
+ menuOpen = true;
72
+ try {
73
+ snippets = getSnippets(ctx);
74
+ enabled = new Set([...enabled].filter((id) => snippets.some((s) => s.id === id)));
75
+ const working = new Set(enabled);
84
76
  let cursor = 0;
85
77
  let listScroll = 0;
86
- let previewScroll = 0;
87
-
88
- const isUp = (data: string) =>
89
- Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.up") : matchesKey(data, Key.up));
90
- const isDown = (data: string) =>
91
- Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.down") : matchesKey(data, Key.down));
92
- const isPageUp = (data: string) =>
93
- Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.pageUp") : matchesKey(data, Key.pageUp));
94
- const isPageDown = (data: string) =>
95
- Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.pageDown") : matchesKey(data, Key.pageDown));
96
- const isConfirm = (data: string) =>
97
- Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.confirm") : matchesKey(data, Key.enter));
98
- const isCancel = (data: string) =>
99
- Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.cancel") : matchesKey(data, Key.escape));
100
-
101
- const itemRow = (snippet: Snippet, idx: number, width: number): string => {
102
- const pointer = idx === cursor ? theme.fg("accent", "> ") : " ";
103
- const checkbox = working.has(snippet.id) ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
104
- const desc = snippet.description ? theme.fg("dim", ` — ${snippet.description}`) : "";
105
- return truncateToWidth(`${pointer}${checkbox} ${theme.bold(snippet.name)}${desc}`, width);
106
- };
107
-
108
- const buildListRows = (width: number): { text: string; itemIndex: number | null }[] => {
109
- const rows: { text: string; itemIndex: number | null }[] = [];
110
- if (prepends.length > 0) {
111
- rows.push({ text: theme.fg("dim", "↑ PREPEND — added before your message"), itemIndex: null });
112
- prepends.forEach((s, i) => rows.push({ text: itemRow(s, i, width), itemIndex: i }));
113
- }
114
- if (prepends.length > 0 && appends.length > 0) {
115
- rows.push({ text: "", itemIndex: null });
116
- }
117
- if (appends.length > 0) {
118
- rows.push({ text: theme.fg("dim", "↓ APPEND — added after your message"), itemIndex: null });
119
- appends.forEach((s, i) =>
120
- rows.push({
121
- text: itemRow(s, prepends.length + i, width),
122
- itemIndex: prepends.length + i,
123
- }),
124
- );
125
- }
126
- return rows;
127
- };
128
-
129
- const buildPreviewRows = (snippet: Snippet, width: number): string[] => {
130
- const rows: string[] = [];
131
- rows.push(truncateToWidth(theme.bold(snippet.name), width));
132
- const sourceInfo = snippet.source ? ` · [${snippet.source}]` : "";
133
- rows.push(
134
- truncateToWidth(
135
- theme.fg("dim", `${snippet.placement} · order ${snippet.order}${sourceInfo} · ${snippet.id}`),
136
- width,
137
- ),
138
- );
139
- rows.push(theme.fg("dim", "─".repeat(Math.min(width, 40))));
140
- for (const line of snippet.body.split("\n")) {
141
- for (const wrapped of wrapTextWithAnsi(line, width)) {
142
- rows.push(truncateToWidth(wrapped, width));
143
- }
144
- }
145
- return rows;
146
- };
147
-
148
- const viewport = (
149
- lines: string[],
150
- scroll: number,
151
- maxView: number,
152
- focusRow?: number,
153
- ): { out: string[]; scroll: number } => {
154
- const clipped = lines.length > maxView;
155
- const view = clipped ? Math.max(1, maxView - 2) : maxView;
156
-
157
- let s = Math.min(Math.max(0, scroll), Math.max(0, lines.length - view));
158
- if (focusRow !== undefined) {
159
- if (focusRow < s) s = focusRow;
160
- else if (focusRow >= s + view) s = focusRow - view + 1;
161
- }
162
78
 
163
- const visible = lines.slice(s, s + view);
164
- if (!clipped) return { out: visible, scroll: s };
165
-
166
- const above = s;
167
- const below = lines.length - (s + view);
168
- return {
169
- out: [
170
- above > 0 ? theme.fg("dim", ` ↑ ${above} more`) : "",
171
- ...visible,
172
- below > 0 ? theme.fg("dim", ` ↓ ${below} more`) : "",
173
- ],
174
- scroll: s,
175
- };
176
- };
177
-
178
- return {
179
- render(width: number): string[] {
180
- const maxView = Math.max(5, tui.terminal.rows - 10);
181
-
182
- let content: string[];
183
- let title: string;
184
- let hints: string;
185
- if (mode === "list") {
186
- const rows = buildListRows(width);
187
- const cursorRow = rows.findIndex((r) => r.itemIndex === cursor);
188
- const v = viewport(
189
- rows.map((r) => r.text),
190
- listScroll,
191
- maxView,
192
- cursorRow >= 0 ? cursorRow : 0,
79
+ while (true) {
80
+ const confirmed = await ctx.ui.custom<boolean | { manage: true; id?: string }>((tui, theme, keybindings, done) => {
81
+ const prepends = snippets.filter((s) => s.placement === "prepend");
82
+ const appends = snippets.filter((s) => s.placement === "append");
83
+ const items = [...prepends, ...appends];
84
+
85
+ let mode: "list" | "preview" = "list";
86
+ let previewScroll = 0;
87
+ let finished = false;
88
+ const finish = (result: boolean | { manage: true; id?: string }) => {
89
+ if (finished) return;
90
+ finished = true;
91
+ done(result);
92
+ };
93
+
94
+ const isUp = (data: string) =>
95
+ Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.up") : matchesKey(data, Key.up));
96
+ const isDown = (data: string) =>
97
+ Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.down") : matchesKey(data, Key.down));
98
+ const isPageUp = (data: string) =>
99
+ Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.pageUp") : matchesKey(data, Key.pageUp));
100
+ const isPageDown = (data: string) =>
101
+ Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.pageDown") : matchesKey(data, Key.pageDown));
102
+ const isConfirm = (data: string) =>
103
+ Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.confirm") : matchesKey(data, Key.enter));
104
+ const isCancel = (data: string) =>
105
+ Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.cancel") : matchesKey(data, Key.escape));
106
+
107
+ const itemRow = (snippet: Snippet, idx: number, width: number): string => {
108
+ const pointer = idx === cursor ? theme.fg("accent", "> ") : " ";
109
+ const checkbox = working.has(snippet.id) ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
110
+ const desc = snippet.description ? theme.fg("dim", ` — ${snippet.description}`) : "";
111
+ return truncateToWidth(`${pointer}${checkbox} ${theme.bold(snippet.name)} ${theme.fg("dim", `[${snippet.source}]`)}${desc}`, width);
112
+ };
113
+
114
+ const buildListRows = (width: number): { text: string; itemIndex: number | null }[] => {
115
+ const rows: { text: string; itemIndex: number | null }[] = [];
116
+ if (prepends.length > 0) {
117
+ rows.push({ text: theme.fg("dim", "↑ PREPEND — added before your message"), itemIndex: null });
118
+ prepends.forEach((s, i) => rows.push({ text: itemRow(s, i, width), itemIndex: i }));
119
+ }
120
+ if (prepends.length > 0 && appends.length > 0) {
121
+ rows.push({ text: "", itemIndex: null });
122
+ }
123
+ if (appends.length > 0) {
124
+ rows.push({ text: theme.fg("dim", "↓ APPEND — added after your message"), itemIndex: null });
125
+ appends.forEach((s, i) =>
126
+ rows.push({
127
+ text: itemRow(s, prepends.length + i, width),
128
+ itemIndex: prepends.length + i,
129
+ }),
130
+ );
131
+ }
132
+ if (items.length === 0) rows.push({ text: truncateToWidth("No snippets. Press Ctrl+E to create one or restore hidden snippets.", width), itemIndex: null });
133
+ return rows;
134
+ };
135
+
136
+ const buildPreviewRows = (snippet: Snippet, width: number): string[] => {
137
+ const rows: string[] = [];
138
+ rows.push(truncateToWidth(theme.bold(snippet.name), width));
139
+ const sourceInfo = snippet.source ? ` · [${snippet.source}]` : "";
140
+ rows.push(
141
+ truncateToWidth(
142
+ theme.fg("dim", `${snippet.placement} · order ${snippet.order}${sourceInfo} · ${snippet.id}`),
143
+ width,
144
+ ),
193
145
  );
194
- content = v.out;
195
- listScroll = v.scroll;
196
- title = "Prompt snippets";
197
- hints = "↑↓ navigate • Space toggle • Tab preview • Enter apply • Esc cancel";
198
- } else {
199
- const snippet = items[cursor];
200
- const rows = buildPreviewRows(snippet, width);
201
- const v = viewport(rows, previewScroll, maxView);
202
- content = v.out;
203
- previewScroll = v.scroll;
204
- title = `Preview: ${snippet.name}`;
205
- hints = "↑↓ scroll • Tab/Esc back";
206
- }
207
-
208
- return [
209
- theme.fg("accent", "─".repeat(width)),
210
- truncateToWidth(` ${theme.fg("accent", theme.bold(title))}`, width),
211
- "",
212
- ...content,
213
- "",
214
- truncateToWidth(theme.fg("dim", ` ${hints}`), width),
215
- theme.fg("accent", "─".repeat(width)),
216
- ];
217
- },
218
- invalidate() {},
219
- handleInput(data: string) {
220
- const maxView = Math.max(5, tui.terminal.rows - 10);
221
- const pageSize = Math.max(1, maxView - 2);
222
-
223
- if (mode === "list") {
224
- if (isUp(data)) {
225
- cursor = (cursor - 1 + items.length) % items.length;
226
- tui.requestRender();
227
- } else if (isDown(data)) {
228
- cursor = (cursor + 1) % items.length;
229
- tui.requestRender();
230
- } else if (isPageUp(data)) {
231
- cursor = Math.max(0, cursor - pageSize);
232
- tui.requestRender();
233
- } else if (isPageDown(data)) {
234
- cursor = Math.min(items.length - 1, cursor + pageSize);
235
- tui.requestRender();
236
- } else if (matchesKey(data, Key.space)) {
237
- const id = items[cursor].id;
238
- if (working.has(id)) working.delete(id);
239
- else working.add(id);
240
- tui.requestRender();
241
- } else if (matchesKey(data, Key.tab)) {
242
- mode = "preview";
243
- previewScroll = 0;
244
- tui.requestRender();
245
- } else if (isConfirm(data)) {
246
- done(true);
247
- } else if (isCancel(data)) {
248
- done(false);
146
+ rows.push(theme.fg("dim", "─".repeat(Math.min(width, 40))));
147
+ for (const line of snippet.body.split("\n")) {
148
+ for (const wrapped of wrapTextWithAnsi(line, width)) {
149
+ rows.push(truncateToWidth(wrapped, width));
150
+ }
249
151
  }
250
- } else {
251
- if (isUp(data)) {
252
- previewScroll = Math.max(0, previewScroll - 1);
253
- tui.requestRender();
254
- } else if (isDown(data)) {
255
- previewScroll++;
256
- tui.requestRender();
257
- } else if (isPageUp(data)) {
258
- previewScroll = Math.max(0, previewScroll - pageSize);
259
- tui.requestRender();
260
- } else if (isPageDown(data)) {
261
- previewScroll += pageSize;
262
- tui.requestRender();
263
- } else if (matchesKey(data, Key.tab) || isCancel(data)) {
264
- mode = "list";
265
- tui.requestRender();
152
+ return rows;
153
+ };
154
+
155
+ const viewport = (
156
+ lines: string[],
157
+ scroll: number,
158
+ maxView: number,
159
+ focusRow?: number,
160
+ ): { out: string[]; scroll: number } => {
161
+ const clipped = lines.length > maxView;
162
+ const view = clipped ? Math.max(1, maxView - 2) : maxView;
163
+
164
+ let s = Math.min(Math.max(0, scroll), Math.max(0, lines.length - view));
165
+ if (focusRow !== undefined) {
166
+ if (focusRow < s) s = focusRow;
167
+ else if (focusRow >= s + view) s = focusRow - view + 1;
266
168
  }
267
- }
268
- },
269
- };
270
- });
271
169
 
272
- if (confirmed) {
273
- enabled = working;
170
+ const visible = lines.slice(s, s + view);
171
+ if (!clipped) return { out: visible, scroll: s };
172
+
173
+ const above = s;
174
+ const below = lines.length - (s + view);
175
+ return {
176
+ out: [
177
+ above > 0 ? theme.fg("dim", ` ↑ ${above} more`) : "",
178
+ ...visible,
179
+ below > 0 ? theme.fg("dim", ` ↓ ${below} more`) : "",
180
+ ],
181
+ scroll: s,
182
+ };
183
+ };
184
+
185
+ return {
186
+ render(width: number): string[] {
187
+ const maxView = Math.max(5, tui.terminal.rows - 10);
188
+
189
+ let content: string[];
190
+ let title: string;
191
+ let hints: string;
192
+ if (mode === "list") {
193
+ const rows = buildListRows(width);
194
+ const cursorRow = rows.findIndex((r) => r.itemIndex === cursor);
195
+ const v = viewport(
196
+ rows.map((r) => truncateToWidth(r.text, width)),
197
+ listScroll,
198
+ maxView,
199
+ cursorRow >= 0 ? cursorRow : 0,
200
+ );
201
+ content = v.out;
202
+ listScroll = v.scroll;
203
+ title = "Prompt snippets";
204
+ hints = "↑↓ navigate • Space toggle • Tab preview • Enter apply • Esc cancel";
205
+ } else {
206
+ const snippet = items[cursor];
207
+ const rows = buildPreviewRows(snippet, width);
208
+ const v = viewport(rows, previewScroll, maxView);
209
+ content = v.out;
210
+ previewScroll = v.scroll;
211
+ title = `Preview: ${snippet.name}`;
212
+ hints = "↑↓ scroll • Tab/Esc back";
213
+ }
214
+
215
+ return [
216
+ theme.fg("accent", "─".repeat(width)),
217
+ truncateToWidth(` ${theme.fg("accent", theme.bold(title))}`, width),
218
+ "",
219
+ ...content,
220
+ "",
221
+ truncateToWidth(theme.fg("dim", ` ${hints}`), width),
222
+ ...(mode === "list" ? [truncateToWidth(theme.fg("dim", " Ctrl+E manage snippets (new / edit / delete)"), width)] : []),
223
+ theme.fg("accent", "─".repeat(width)),
224
+ ];
225
+ },
226
+ invalidate() {},
227
+ handleInput(data: string) {
228
+ if (finished) return;
229
+ const maxView = Math.max(5, tui.terminal.rows - 10);
230
+ const pageSize = Math.max(1, maxView - 2);
231
+
232
+ if (mode === "list") {
233
+ if (matchesKey(data, Key.ctrl("e"))) {
234
+ finish({ manage: true, id: items[cursor]?.id });
235
+ } else if (isConfirm(data)) {
236
+ finish(true);
237
+ } else if (isCancel(data)) {
238
+ finish(false);
239
+ } else if (items.length === 0) {
240
+ return;
241
+ } else if (isUp(data)) {
242
+ cursor = (cursor - 1 + items.length) % items.length;
243
+ tui.requestRender();
244
+ } else if (isDown(data)) {
245
+ cursor = (cursor + 1) % items.length;
246
+ tui.requestRender();
247
+ } else if (isPageUp(data)) {
248
+ cursor = Math.max(0, cursor - pageSize);
249
+ tui.requestRender();
250
+ } else if (isPageDown(data)) {
251
+ cursor = Math.min(items.length - 1, cursor + pageSize);
252
+ tui.requestRender();
253
+ } else if (matchesKey(data, Key.space)) {
254
+ const id = items[cursor].id;
255
+ if (working.has(id)) working.delete(id);
256
+ else working.add(id);
257
+ tui.requestRender();
258
+ } else if (matchesKey(data, Key.tab)) {
259
+ mode = "preview";
260
+ previewScroll = 0;
261
+ tui.requestRender();
262
+ }
263
+ } else {
264
+ if (isUp(data)) {
265
+ previewScroll = Math.max(0, previewScroll - 1);
266
+ tui.requestRender();
267
+ } else if (isDown(data)) {
268
+ previewScroll++;
269
+ tui.requestRender();
270
+ } else if (isPageUp(data)) {
271
+ previewScroll = Math.max(0, previewScroll - pageSize);
272
+ tui.requestRender();
273
+ } else if (isPageDown(data)) {
274
+ previewScroll += pageSize;
275
+ tui.requestRender();
276
+ } else if (matchesKey(data, Key.tab) || isCancel(data)) {
277
+ mode = "list";
278
+ tui.requestRender();
279
+ }
280
+ }
281
+ },
282
+ };
283
+ });
284
+
285
+ if (typeof confirmed === "object" && confirmed?.manage) {
286
+ await manageSnippet(ctx, {
287
+ ...extensionOptions,
288
+ cwd: ctx.cwd,
289
+ isProjectTrusted: typeof ctx.isProjectTrusted === "function" ? ctx.isProjectTrusted() : true,
290
+ }, confirmed.id);
291
+ snippets = getSnippets(ctx);
292
+ const ids = new Set(snippets.map((s) => s.id));
293
+ for (const id of working) if (!ids.has(id)) working.delete(id);
294
+ enabled = new Set([...enabled].filter((id) => ids.has(id)));
295
+ const selectedIndex = snippets.findIndex((s) => s.id === confirmed.id);
296
+ cursor = selectedIndex >= 0 ? selectedIndex : Math.max(0, Math.min(cursor, snippets.length - 1));
297
+ updateWidget(ctx);
298
+ continue;
299
+ }
300
+ if (confirmed) enabled = working;
301
+ break;
302
+ }
303
+ } finally {
304
+ menuOpen = false;
305
+ updateWidget(ctx);
274
306
  }
275
- updateWidget(ctx);
276
307
  }
277
308
 
278
309
  pi.on("session_start", (_event, ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zle13/pi-prompt-snippets",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Mix-and-match prompt snippets that prepend or append rules to messages in Pi",
5
5
  "type": "module",
6
6
  "keywords": [