@zle13/pi-prompt-snippets 0.1.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 ADDED
@@ -0,0 +1,118 @@
1
+ # @zle13/pi-prompt-snippets
2
+
3
+ Mix-and-match single-purpose prompt rules that are prepended or appended to your message when you send it in Pi.
4
+
5
+ Unlike full skills, each snippet is a lightweight, standalone instruction — toggle exactly the ones you want for your next message.
6
+
7
+ > **Credits:** Originally created by [Amos Blomqvist](https://github.com/amosblomqvist) in [amosblomqvist/pi-config](https://github.com/amosblomqvist/pi-config).
8
+
9
+ ---
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pi install npm:@zle13/pi-prompt-snippets
15
+ ```
16
+
17
+ Or install locally from source:
18
+
19
+ ```bash
20
+ pi install -l ./packages/prompt-snippets
21
+ ```
22
+
23
+ Then restart Pi or run `/reload` in your active session.
24
+
25
+ ---
26
+
27
+ ## Usage
28
+
29
+ - Press **Alt+S** or run **/snippets** to open the interactive toggle menu.
30
+ - `↑` / `↓` (or custom selection keybindings like `Ctrl+P` / `Ctrl+N`) to navigate items.
31
+ - `Space` to toggle snippet active / inactive.
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
+ - `Enter` to apply your selection.
34
+ - `Esc` to cancel and discard changes.
35
+ - **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
+ - **Active Snippet Indicator:** Active snippets appear as a widget above the editor:
37
+ - `↑ prepend: ...` (accent color) — inserted before your message.
38
+ - `↓ append: ...` (warning color) — inserted after your message.
39
+ - When you send a message, active snippets are merged in order:
40
+ `[Prepend group (sorted by order)] → [Your message] → [Append group (sorted by order)]`, separated by blank lines.
41
+ - **Automatic Reset:** Active toggles reset to all off after every send and at session start.
42
+
43
+ ---
44
+
45
+ ## Multi-Directory Hierarchy
46
+
47
+ Snippets are discovered dynamically across three layers (in increasing precedence):
48
+
49
+ 1. **Bundled package snippets:** Default rules shipped with this extension.
50
+ 2. **Global user snippets:** `~/.pi/agent/snippets/*.md`
51
+ 3. **Project snippets:** `.pi/snippets/*.md` (in your workspace, honored when the project is trusted).
52
+
53
+ Files are re-scanned whenever the menu opens or a message is sent, so edits take effect immediately without restarting Pi.
54
+
55
+ ---
56
+
57
+ ## Customizing, Overriding & Disabling Snippets
58
+
59
+ ### 1. Add Your Own Snippets
60
+
61
+ Create any `.md` file in `~/.pi/agent/snippets/` (for all projects) or `.pi/snippets/` (for the current project):
62
+
63
+ ```markdown
64
+ ---
65
+ name: Concise Output
66
+ description: Short, direct answers without unnecessary fluff
67
+ placement: prepend
68
+ order: 10
69
+ ---
70
+ Keep your response concise. Skip preamble and unnecessary explanation.
71
+ ```
72
+
73
+ ### 2. Override Existing Snippets
74
+
75
+ Create a file with the same filename (e.g. `.pi/snippets/ask-questions.md` or `~/.pi/agent/snippets/ask-questions.md`). Higher-precedence layers completely override lower-precedence ones.
76
+
77
+ ### 3. Disable Default Snippets
78
+
79
+ You can disable default or global snippets in two ways:
80
+
81
+ #### Option A: Frontmatter `disabled: true`
82
+ Create a file with the same filename in `~/.pi/agent/snippets/` or `.pi/snippets/` and set `disabled: true`:
83
+
84
+ ```markdown
85
+ ---
86
+ disabled: true
87
+ ---
88
+ ```
89
+
90
+ #### Option B: Configuration File
91
+ Create `.pi/prompt-snippets.json` (project-level) or `~/.pi/agent/prompt-snippets.json` (global):
92
+
93
+ ```json
94
+ {
95
+ "disabled": [
96
+ "ask-questions",
97
+ "diagnose-report.md"
98
+ ]
99
+ }
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Snippet Frontmatter Reference
105
+
106
+ | Field | Type | Default | Description |
107
+ |---|---|---|---|
108
+ | `name` | `string` | Filename without `.md` | Display name shown in the menu |
109
+ | `description` | `string` | `""` | Description subtitle shown in the menu |
110
+ | `placement` | `"prepend"` \| `"append"` | `"append"` | Whether the rule is placed before or after your prompt |
111
+ | `order` | `number` | `9999` | Sort order within its placement group (lower numbers first) |
112
+ | `disabled` | `boolean` | `false` | When `true`, disables/hides this snippet ID from the list |
113
+
114
+ ---
115
+
116
+ ## License
117
+
118
+ MIT © zle13. Original work © Amos Blomqvist.
@@ -0,0 +1,207 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import type { LoadSnippetsOptions, Snippet, SnippetConfig, SnippetSource } from "./types.js";
6
+
7
+ const extensionDir = dirname(fileURLToPath(import.meta.url));
8
+
9
+ /**
10
+ * Parse frontmatter and body from a snippet markdown file.
11
+ */
12
+ export function parseSnippet(
13
+ filename: string,
14
+ raw: string,
15
+ source: SnippetSource,
16
+ filePath: string,
17
+ ): { snippet: Snippet | null; disabled: boolean } {
18
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
19
+ if (!match) return { snippet: null, disabled: false };
20
+
21
+ const meta: Record<string, string> = {};
22
+ for (const line of match[1].split(/\r?\n/)) {
23
+ const kv = line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*)$/);
24
+ if (kv) meta[kv[1].toLowerCase()] = kv[2].trim().replace(/^["']|["']$/g, "");
25
+ }
26
+
27
+ const disabled =
28
+ meta.disabled === "true" ||
29
+ meta.disabled === "yes" ||
30
+ meta.disabled === "1" ||
31
+ meta.enabled === "false" ||
32
+ meta.enabled === "no" ||
33
+ meta.enabled === "0";
34
+
35
+ if (disabled) {
36
+ return { snippet: null, disabled: true };
37
+ }
38
+
39
+ const body = match[2].trim();
40
+ if (!body) {
41
+ return { snippet: null, disabled: false };
42
+ }
43
+
44
+ const parsedOrder = Number.parseInt(meta.order ?? "", 10);
45
+ return {
46
+ snippet: {
47
+ id: filename,
48
+ name: meta.name || filename.replace(/\.md$/i, ""),
49
+ description: meta.description ?? "",
50
+ placement: meta.placement === "prepend" ? "prepend" : "append",
51
+ order: Number.isFinite(parsedOrder) ? parsedOrder : 9999,
52
+ body,
53
+ source,
54
+ filePath,
55
+ },
56
+ disabled: false,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Load optional prompt-snippets.json config from a directory.
62
+ */
63
+ export function loadSnippetConfigFile(configPath: string): SnippetConfig | null {
64
+ if (!existsSync(configPath)) return null;
65
+ try {
66
+ const raw = readFileSync(configPath, "utf-8");
67
+ const parsed = JSON.parse(raw);
68
+ if (parsed && typeof parsed === "object") {
69
+ return {
70
+ disabled: Array.isArray(parsed.disabled) ? parsed.disabled.map(String) : undefined,
71
+ };
72
+ }
73
+ } catch {
74
+ // Ignore corrupt or unreadable config files
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * Resolve the directory where bundled default snippets live.
81
+ */
82
+ export function resolveBundledSnippetsDir(customDir?: string): string | null {
83
+ if (customDir && existsSync(customDir)) {
84
+ return customDir;
85
+ }
86
+ const candidate1 = join(extensionDir, "snippets");
87
+ if (existsSync(candidate1)) return candidate1;
88
+
89
+ const candidate2 = join(extensionDir, "..", "snippets");
90
+ if (existsSync(candidate2)) return candidate2;
91
+
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Load snippets from a single directory.
97
+ */
98
+ export function scanSnippetDirectory(
99
+ dir: string,
100
+ source: SnippetSource,
101
+ ): { snippets: Snippet[]; disabledIds: Set<string> } {
102
+ const snippets: Snippet[] = [];
103
+ const disabledIds = new Set<string>();
104
+
105
+ if (!existsSync(dir)) {
106
+ return { snippets, disabledIds };
107
+ }
108
+
109
+ try {
110
+ for (const file of readdirSync(dir)) {
111
+ if (!file.toLowerCase().endsWith(".md")) continue;
112
+ const fullPath = join(dir, file);
113
+ try {
114
+ const content = readFileSync(fullPath, "utf-8");
115
+ const { snippet, disabled } = parseSnippet(file, content, source, fullPath);
116
+ if (disabled) {
117
+ disabledIds.add(file);
118
+ } else if (snippet) {
119
+ snippets.push(snippet);
120
+ }
121
+ } catch {
122
+ // Skip unreadable files
123
+ }
124
+ }
125
+ } catch {
126
+ // Skip unreadable directories
127
+ }
128
+
129
+ return { snippets, disabledIds };
130
+ }
131
+
132
+ /**
133
+ * Load all snippets hierarchically:
134
+ * 1. Bundled defaults (<package>/snippets)
135
+ * 2. Global user snippets (~/.pi/agent/snippets)
136
+ * 3. Project snippets (<cwd>/.pi/snippets)
137
+ *
138
+ * Snippets with the same filename override lower-priority ones.
139
+ * Snippets can be disabled via frontmatter (`disabled: true`) or in prompt-snippets.json.
140
+ */
141
+ export function loadSnippets(options: LoadSnippetsOptions = {}): Snippet[] {
142
+ const map = new Map<string, Snippet>();
143
+ const disabledConfigs = new Set<string>();
144
+
145
+ // 1. Bundled defaults
146
+ const bundledDir = resolveBundledSnippetsDir(options.bundledDir);
147
+ if (bundledDir) {
148
+ const { snippets, disabledIds } = scanSnippetDirectory(bundledDir, "bundled");
149
+ for (const id of disabledIds) map.delete(id);
150
+ for (const s of snippets) map.set(s.id, s);
151
+ }
152
+
153
+ // 2. Global snippets & config (~/.pi/agent)
154
+ let agentDir: string;
155
+ try {
156
+ agentDir = options.agentDir ?? getAgentDir();
157
+ } catch {
158
+ agentDir = options.agentDir ?? join(process.env.HOME || "", ".pi", "agent");
159
+ }
160
+
161
+ if (agentDir) {
162
+ const globalSnippetsDir = join(agentDir, "snippets");
163
+ const { snippets, disabledIds } = scanSnippetDirectory(globalSnippetsDir, "global");
164
+ for (const id of disabledIds) map.delete(id);
165
+ for (const s of snippets) map.set(s.id, s);
166
+
167
+ const globalConfigFile = join(agentDir, "prompt-snippets.json");
168
+ const globalConfig = loadSnippetConfigFile(globalConfigFile);
169
+ if (globalConfig?.disabled) {
170
+ for (const id of globalConfig.disabled) disabledConfigs.add(id);
171
+ }
172
+ }
173
+
174
+ // 3. Project snippets & config (<cwd>/.pi)
175
+ const isTrusted = options.isProjectTrusted !== false;
176
+ if (options.cwd && isTrusted) {
177
+ const configDirName = CONFIG_DIR_NAME || ".pi";
178
+ const projectSnippetsDir = join(options.cwd, configDirName, "snippets");
179
+ const { snippets, disabledIds } = scanSnippetDirectory(projectSnippetsDir, "project");
180
+ for (const id of disabledIds) map.delete(id);
181
+ for (const s of snippets) map.set(s.id, s);
182
+
183
+ const projectConfigFile = join(options.cwd, configDirName, "prompt-snippets.json");
184
+ const projectConfig = loadSnippetConfigFile(projectConfigFile);
185
+ if (projectConfig?.disabled) {
186
+ for (const id of projectConfig.disabled) disabledConfigs.add(id);
187
+ }
188
+ }
189
+
190
+ // Filter out explicitly disabled snippet IDs/names from configuration
191
+ let result = Array.from(map.values()).filter((s) => {
192
+ const idWithoutExt = s.id.replace(/\.md$/i, "");
193
+ return (
194
+ !disabledConfigs.has(s.id) &&
195
+ !disabledConfigs.has(idWithoutExt) &&
196
+ !disabledConfigs.has(s.name)
197
+ );
198
+ });
199
+
200
+ const byOrder = (a: Snippet, b: Snippet) =>
201
+ a.order - b.order || a.name.localeCompare(b.name);
202
+
203
+ return [
204
+ ...result.filter((s) => s.placement === "prepend").sort(byOrder),
205
+ ...result.filter((s) => s.placement === "append").sort(byOrder),
206
+ ];
207
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Prompt Snippets — mix-and-match single-purpose prompt rules for Pi.
3
+ *
4
+ * Originally created by Amos Blomqvist (https://github.com/amosblomqvist/pi-config).
5
+ *
6
+ * Snippets are markdown files with YAML frontmatter stored in:
7
+ * - Bundled package snippets (<package>/snippets/)
8
+ * - Global user snippets (~/.pi/agent/snippets/)
9
+ * - Project snippets (<cwd>/.pi/snippets/)
10
+ */
11
+
12
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
14
+ import { loadSnippets } from "./loader.js";
15
+ import type { LoadSnippetsOptions, Snippet } from "./types.js";
16
+
17
+ const WIDGET_ID = "prompt-snippets";
18
+
19
+ export function createPromptSnippetsExtension(extensionOptions: LoadSnippetsOptions = {}) {
20
+ return function promptSnippetsExtension(pi: ExtensionAPI) {
21
+ let snippets: Snippet[] = [];
22
+ let enabled = new Set<string>();
23
+
24
+ function getSnippets(ctx: ExtensionContext): Snippet[] {
25
+ const isTrusted = typeof ctx.isProjectTrusted === "function" ? ctx.isProjectTrusted() : true;
26
+ return loadSnippets({
27
+ ...extensionOptions,
28
+ cwd: ctx.cwd,
29
+ isProjectTrusted: isTrusted,
30
+ });
31
+ }
32
+
33
+ function updateWidget(ctx: ExtensionContext) {
34
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
35
+ const active = snippets.filter((s) => enabled.has(s.id));
36
+ const prepends = active.filter((s) => s.placement === "prepend");
37
+ const appends = active.filter((s) => s.placement === "append");
38
+
39
+ if (prepends.length === 0 && appends.length === 0) {
40
+ ctx.ui.setWidget(WIDGET_ID, undefined);
41
+ return;
42
+ }
43
+
44
+ const theme = ctx.ui.theme;
45
+ const lines: string[] = [];
46
+ if (prepends.length > 0) {
47
+ lines.push(theme.fg("accent", `↑ prepend: ${prepends.map((s) => s.name).join(" · ")}`));
48
+ }
49
+ if (appends.length > 0) {
50
+ lines.push(theme.fg("warning", `↓ append: ${appends.map((s) => s.name).join(" · ")}`));
51
+ }
52
+ ctx.ui.setWidget(WIDGET_ID, lines);
53
+ }
54
+
55
+ async function openMenu(ctx: ExtensionContext) {
56
+ if (ctx.mode !== "tui") {
57
+ ctx.ui.notify("Snippet menu requires interactive mode", "warning");
58
+ return;
59
+ }
60
+
61
+ snippets = getSnippets(ctx);
62
+ enabled = new Set([...enabled].filter((id) => snippets.some((s) => s.id === id)));
63
+
64
+ if (snippets.length === 0) {
65
+ ctx.ui.notify("No snippets found in bundled, global, or project snippet directories", "warning");
66
+ updateWidget(ctx);
67
+ return;
68
+ }
69
+
70
+ const working = new Set(enabled);
71
+
72
+ const confirmed = await ctx.ui.custom<boolean>((tui, theme, keybindings, done) => {
73
+ const prepends = snippets.filter((s) => s.placement === "prepend");
74
+ const appends = snippets.filter((s) => s.placement === "append");
75
+ const items = [...prepends, ...appends];
76
+
77
+ let mode: "list" | "preview" = "list";
78
+ let cursor = 0;
79
+ let listScroll = 0;
80
+ let previewScroll = 0;
81
+
82
+ const isUp = (data: string) =>
83
+ Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.up") : matchesKey(data, Key.up));
84
+ const isDown = (data: string) =>
85
+ Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.down") : matchesKey(data, Key.down));
86
+ const isPageUp = (data: string) =>
87
+ Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.pageUp") : matchesKey(data, Key.pageUp));
88
+ const isPageDown = (data: string) =>
89
+ Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.pageDown") : matchesKey(data, Key.pageDown));
90
+ const isConfirm = (data: string) =>
91
+ Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.confirm") : matchesKey(data, Key.enter));
92
+ const isCancel = (data: string) =>
93
+ Boolean(keybindings?.matches ? keybindings.matches(data, "tui.select.cancel") : matchesKey(data, Key.escape));
94
+
95
+ const itemRow = (snippet: Snippet, idx: number, width: number): string => {
96
+ const pointer = idx === cursor ? theme.fg("accent", "> ") : " ";
97
+ const checkbox = working.has(snippet.id) ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
98
+ const desc = snippet.description ? theme.fg("dim", ` — ${snippet.description}`) : "";
99
+ return truncateToWidth(`${pointer}${checkbox} ${theme.bold(snippet.name)}${desc}`, width);
100
+ };
101
+
102
+ const buildListRows = (width: number): { text: string; itemIndex: number | null }[] => {
103
+ const rows: { text: string; itemIndex: number | null }[] = [];
104
+ if (prepends.length > 0) {
105
+ rows.push({ text: theme.fg("dim", "↑ PREPEND — added before your message"), itemIndex: null });
106
+ prepends.forEach((s, i) => rows.push({ text: itemRow(s, i, width), itemIndex: i }));
107
+ }
108
+ if (prepends.length > 0 && appends.length > 0) {
109
+ rows.push({ text: "", itemIndex: null });
110
+ }
111
+ if (appends.length > 0) {
112
+ rows.push({ text: theme.fg("dim", "↓ APPEND — added after your message"), itemIndex: null });
113
+ appends.forEach((s, i) =>
114
+ rows.push({
115
+ text: itemRow(s, prepends.length + i, width),
116
+ itemIndex: prepends.length + i,
117
+ }),
118
+ );
119
+ }
120
+ return rows;
121
+ };
122
+
123
+ const buildPreviewRows = (snippet: Snippet, width: number): string[] => {
124
+ const rows: string[] = [];
125
+ rows.push(truncateToWidth(theme.bold(snippet.name), width));
126
+ const sourceInfo = snippet.source ? ` · [${snippet.source}]` : "";
127
+ rows.push(
128
+ truncateToWidth(
129
+ theme.fg("dim", `${snippet.placement} · order ${snippet.order}${sourceInfo} · ${snippet.id}`),
130
+ width,
131
+ ),
132
+ );
133
+ rows.push(theme.fg("dim", "─".repeat(Math.min(width, 40))));
134
+ for (const line of snippet.body.split("\n")) {
135
+ for (const wrapped of wrapTextWithAnsi(line, width)) {
136
+ rows.push(truncateToWidth(wrapped, width));
137
+ }
138
+ }
139
+ return rows;
140
+ };
141
+
142
+ const viewport = (
143
+ lines: string[],
144
+ scroll: number,
145
+ maxView: number,
146
+ focusRow?: number,
147
+ ): { out: string[]; scroll: number } => {
148
+ const clipped = lines.length > maxView;
149
+ const view = clipped ? Math.max(1, maxView - 2) : maxView;
150
+
151
+ let s = Math.min(Math.max(0, scroll), Math.max(0, lines.length - view));
152
+ if (focusRow !== undefined) {
153
+ if (focusRow < s) s = focusRow;
154
+ else if (focusRow >= s + view) s = focusRow - view + 1;
155
+ }
156
+
157
+ const visible = lines.slice(s, s + view);
158
+ if (!clipped) return { out: visible, scroll: s };
159
+
160
+ const above = s;
161
+ const below = lines.length - (s + view);
162
+ return {
163
+ out: [
164
+ above > 0 ? theme.fg("dim", ` ↑ ${above} more`) : "",
165
+ ...visible,
166
+ below > 0 ? theme.fg("dim", ` ↓ ${below} more`) : "",
167
+ ],
168
+ scroll: s,
169
+ };
170
+ };
171
+
172
+ return {
173
+ render(width: number): string[] {
174
+ const maxView = Math.max(5, tui.terminal.rows - 10);
175
+
176
+ let content: string[];
177
+ let title: string;
178
+ let hints: string;
179
+ if (mode === "list") {
180
+ const rows = buildListRows(width);
181
+ const cursorRow = rows.findIndex((r) => r.itemIndex === cursor);
182
+ const v = viewport(
183
+ rows.map((r) => r.text),
184
+ listScroll,
185
+ maxView,
186
+ cursorRow >= 0 ? cursorRow : 0,
187
+ );
188
+ content = v.out;
189
+ listScroll = v.scroll;
190
+ title = "Prompt snippets";
191
+ hints = "↑↓ navigate • Space toggle • Tab preview • Enter apply • Esc cancel";
192
+ } else {
193
+ const snippet = items[cursor];
194
+ const rows = buildPreviewRows(snippet, width);
195
+ const v = viewport(rows, previewScroll, maxView);
196
+ content = v.out;
197
+ previewScroll = v.scroll;
198
+ title = `Preview: ${snippet.name}`;
199
+ hints = "↑↓ scroll • Tab/Esc back";
200
+ }
201
+
202
+ return [
203
+ theme.fg("accent", "─".repeat(width)),
204
+ truncateToWidth(` ${theme.fg("accent", theme.bold(title))}`, width),
205
+ "",
206
+ ...content,
207
+ "",
208
+ truncateToWidth(theme.fg("dim", ` ${hints}`), width),
209
+ theme.fg("accent", "─".repeat(width)),
210
+ ];
211
+ },
212
+ invalidate() {},
213
+ handleInput(data: string) {
214
+ const maxView = Math.max(5, tui.terminal.rows - 10);
215
+ const pageSize = Math.max(1, maxView - 2);
216
+
217
+ if (mode === "list") {
218
+ if (isUp(data)) {
219
+ cursor = (cursor - 1 + items.length) % items.length;
220
+ tui.requestRender();
221
+ } else if (isDown(data)) {
222
+ cursor = (cursor + 1) % items.length;
223
+ tui.requestRender();
224
+ } else if (isPageUp(data)) {
225
+ cursor = Math.max(0, cursor - pageSize);
226
+ tui.requestRender();
227
+ } else if (isPageDown(data)) {
228
+ cursor = Math.min(items.length - 1, cursor + pageSize);
229
+ tui.requestRender();
230
+ } else if (matchesKey(data, Key.space)) {
231
+ const id = items[cursor].id;
232
+ if (working.has(id)) working.delete(id);
233
+ else working.add(id);
234
+ tui.requestRender();
235
+ } else if (matchesKey(data, Key.tab)) {
236
+ mode = "preview";
237
+ previewScroll = 0;
238
+ tui.requestRender();
239
+ } else if (isConfirm(data)) {
240
+ done(true);
241
+ } else if (isCancel(data)) {
242
+ done(false);
243
+ }
244
+ } else {
245
+ if (isUp(data)) {
246
+ previewScroll = Math.max(0, previewScroll - 1);
247
+ tui.requestRender();
248
+ } else if (isDown(data)) {
249
+ previewScroll++;
250
+ tui.requestRender();
251
+ } else if (isPageUp(data)) {
252
+ previewScroll = Math.max(0, previewScroll - pageSize);
253
+ tui.requestRender();
254
+ } else if (isPageDown(data)) {
255
+ previewScroll += pageSize;
256
+ tui.requestRender();
257
+ } else if (matchesKey(data, Key.tab) || isCancel(data)) {
258
+ mode = "list";
259
+ tui.requestRender();
260
+ }
261
+ }
262
+ },
263
+ };
264
+ });
265
+
266
+ if (confirmed) {
267
+ enabled = working;
268
+ }
269
+ updateWidget(ctx);
270
+ }
271
+
272
+ pi.on("session_start", (_event, ctx) => {
273
+ enabled = new Set();
274
+ snippets = getSnippets(ctx);
275
+ updateWidget(ctx);
276
+ });
277
+
278
+ pi.on("session_shutdown", (_event, ctx) => {
279
+ enabled = new Set();
280
+ if (ctx.hasUI && ctx.mode === "tui") {
281
+ ctx.ui.setWidget(WIDGET_ID, undefined);
282
+ }
283
+ });
284
+
285
+ pi.on("input", async (event, ctx) => {
286
+ if (enabled.size === 0) return;
287
+
288
+ snippets = getSnippets(ctx);
289
+ const active = snippets.filter((s) => enabled.has(s.id));
290
+ enabled = new Set();
291
+ updateWidget(ctx);
292
+
293
+ if (active.length === 0) return;
294
+
295
+ const prependBodies = active.filter((s) => s.placement === "prepend").map((s) => s.body);
296
+ const appendBodies = active.filter((s) => s.placement === "append").map((s) => s.body);
297
+ return {
298
+ action: "transform",
299
+ text: [...prependBodies, event.text, ...appendBodies].join("\n\n"),
300
+ };
301
+ });
302
+
303
+ pi.registerShortcut("alt+s", {
304
+ description: "Toggle prompt snippets",
305
+ handler: async (ctx) => {
306
+ await openMenu(ctx);
307
+ },
308
+ });
309
+
310
+ pi.registerCommand("snippets", {
311
+ description: "Open the prompt snippet toggle menu",
312
+ handler: async (_args, ctx) => {
313
+ await openMenu(ctx);
314
+ },
315
+ });
316
+ };
317
+ }
318
+
319
+ export default createPromptSnippetsExtension();
@@ -0,0 +1,37 @@
1
+ export type SnippetPlacement = "prepend" | "append";
2
+ export type SnippetSource = "bundled" | "global" | "project";
3
+
4
+ export interface Snippet {
5
+ /** Filename identifier, e.g. "ask-questions.md" */
6
+ id: string;
7
+ /** Display name */
8
+ name: string;
9
+ /** Short description shown in menu */
10
+ description: string;
11
+ /** Placement before or after user message */
12
+ placement: SnippetPlacement;
13
+ /** Sort order within placement group (ascending) */
14
+ order: number;
15
+ /** Prompt content inserted into message */
16
+ body: string;
17
+ /** Origin of snippet definition */
18
+ source: SnippetSource;
19
+ /** File path on disk */
20
+ filePath: string;
21
+ }
22
+
23
+ export interface SnippetConfig {
24
+ /** List of snippet IDs (with or without .md) to disable */
25
+ disabled?: string[];
26
+ }
27
+
28
+ export interface LoadSnippetsOptions {
29
+ /** Current working directory for project snippets */
30
+ cwd?: string;
31
+ /** Pi agent directory (defaults to getAgentDir()) */
32
+ agentDir?: string;
33
+ /** Bundled snippets directory in extension/package */
34
+ bundledDir?: string;
35
+ /** Whether project-local configuration and snippets are trusted */
36
+ isProjectTrusted?: boolean;
37
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@zle13/pi-prompt-snippets",
3
+ "version": "0.1.0",
4
+ "description": "Mix-and-match prompt snippets that prepend or append rules to messages in Pi",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "author": "zle13",
10
+ "license": "MIT",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/LeZH13/zle-pi.git",
14
+ "directory": "packages/prompt-snippets"
15
+ },
16
+ "files": [
17
+ "extensions",
18
+ "snippets",
19
+ "README.md"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "peerDependencies": {
25
+ "@earendil-works/pi-agent-core": "*",
26
+ "@earendil-works/pi-ai": "*",
27
+ "@earendil-works/pi-coding-agent": "*",
28
+ "@earendil-works/pi-tui": "*",
29
+ "typebox": "*"
30
+ },
31
+ "pi": {
32
+ "extensions": [
33
+ "./extensions/prompt-snippets.ts"
34
+ ]
35
+ },
36
+ "scripts": {
37
+ "test": "vitest --run",
38
+ "typecheck": "tsc --noEmit"
39
+ }
40
+ }
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: Ask questions
3
+ description: Ask questions to reach a shared understanding.
4
+ placement: append
5
+ order: 10
6
+ ---
7
+ Ask questions until you are 100% sure you know exactly what to do.
8
+ Do not act on this until I confirm we have reached a shared understanding.
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: Delegate exploration
3
+ description: Keep context lean. Subagents explore, you verify critical parts
4
+ placement: append
5
+ order: 30
6
+ ---
7
+ Keep your context window lean. Delegate codebase exploration to subagents — ask them specific, targeted questions instead of reading many files yourself. Read files directly only to verify critical parts.
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: Diagnose, don't fix
3
+ description: Read-only, report findings and a proposed fix
4
+ placement: append
5
+ order: 40
6
+ ---
7
+ Investigate and diagnose the issue. Do not change any code. Report back with your findings and a proposed fix. Do not apply the fix yet.
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: Orchestrator mode
3
+ description: Pure high-level orchestrator session
4
+ placement: prepend
5
+ order: 30
6
+ ---
7
+ This is a pure high-level orchestrator session. Outsource mechanical work — file exploration, code reading, implementation — to subagents. Keep your own context window lean so your reasoning stays sharp; do not bloat it by reading code yourself.
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: Session kickoff
3
+ description: Get oriented, report back before starting work
4
+ placement: prepend
5
+ order: 10
6
+ ---
7
+ Familiarize yourself with this project before we start. Once you have a clear picture, report back. Do not begin any work until we have aligned on what's next.
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: Verify, don't assume
3
+ description: Do not assume — verify
4
+ placement: append
5
+ order: 20
6
+ ---
7
+ Do not assume — verify. Verify critical facts rather than guessing. And ask me, if you cannot verify something. Only begin once you are 100% sure of what to do. If you catch yourself being even slightly unsure, that's a sign to check first.