pi-snippets 1.0.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,109 @@
1
+ # Pi Snippets
2
+
3
+ Zero-keystroke snippet auto-expander for [pi](https://pi.dev).
4
+
5
+ Type a trigger word and it instantly expands on the last keystroke — no delimiter,
6
+ no confirmation needed. Just natural typing flow.
7
+
8
+ ## Quick Start
9
+
10
+ ```bash
11
+ # Install from a local clone
12
+ pi install ./pi-snippets
13
+
14
+ # Or test without installing
15
+ pi -e ./pi-snippets/extensions/index.ts
16
+
17
+ # Install from npm (once published)
18
+ pi install npm:pi-snippets
19
+ ```
20
+
21
+ ## Getting Started
22
+
23
+ **No snippets are bundled.** You start with a blank slate and add exactly what you need.
24
+
25
+ ## Adding Snippets
26
+
27
+ ### Using `/add` (recommended)
28
+
29
+ Add snippets directly from inside pi — no file editing, no `/reload`:
30
+
31
+ ```
32
+ /add eml : user@example.com
33
+ /add lgtm : Looks good to me! 👍
34
+ /add sum : Summarize the following:
35
+
36
+ ```
37
+
38
+ Remove snippets with the interactive `/remove` command — a TUI picker lets you check off which ones to delete.
39
+
40
+ The snippet takes effect **instantly** — start typing the trigger immediately.
41
+ Saved to `~/.pi/agent/snippets.json` so it persists across sessions.
42
+
43
+ ### Editing the file directly
44
+
45
+ You can also edit `~/.pi/agent/snippets.json` directly:
46
+
47
+ ```json
48
+ {
49
+ "sig": "Best regards,\nJane Doe",
50
+ "sum": "Summarize the following:\n\n",
51
+ "lgtm": "Looks good to me! 👍"
52
+ }
53
+ ```
54
+
55
+ Then `/reload` inside pi (or restart).
56
+
57
+ ### Project-level (shared with your team)
58
+
59
+ Create `.pi/snippets.json` in your project root and commit it to git.
60
+ Project snippets only load when the project is trusted.
61
+
62
+ ### Priority (later sources override earlier ones)
63
+
64
+ 1. Project (`.pi/snippets.json`, trusted projects only) — highest priority
65
+ 2. User global (`~/.pi/agent/snippets.json`)
66
+ 3. Bundled defaults (shipped with this package) — lowest priority (fallback)
67
+
68
+ ## How It Works
69
+
70
+ The extension replaces pi's input editor with a custom editor that intercepts every
71
+ keystroke. As soon as you finish typing a trigger word, it expands instantly — the
72
+ last character of the trigger itself fires the expansion.
73
+
74
+ - **Instant expansion:** No delimiter required — the moment you type the final
75
+ character of a trigger word, it expands to your defined text.
76
+ - **Word-boundary safe:** The trigger must be a complete whitespace-delimited token
77
+ (or at the very start of input). Typing `sig` inside `design` won't trigger
78
+ expansion because `sig` is not at a word boundary.
79
+ - **Compatible:** Works alongside other extensions. Wraps any existing custom editor
80
+ via `getEditorComponent()` to preserve composability.
81
+
82
+ ## Trigger Guidelines
83
+
84
+ Choose triggers that are **not common English words or prefixes of other triggers**
85
+ to avoid accidental or premature expansion:
86
+
87
+ - ✅ `sig`, `tpl`, `dbg` — abbreviation-like, unlikely to be typed by accident
88
+ - ⚠️ `fix`, `todo`, `bug` — common words that might trigger mid-sentence
89
+ - ⚠️ `db` / `dbg` — overlapping prefixes can cause the shorter trigger to fire early
90
+ - ❌ `a`, `i`, `q` — single-letter triggers are very prone to false positives
91
+
92
+ Multi-character, abbreviation-style triggers work best. Avoid triggers that are
93
+ prefixes of each other.
94
+
95
+ ## Publishing to npm
96
+
97
+ ```bash
98
+ cd pi-snippets
99
+ npm publish --access public
100
+ ```
101
+
102
+ Then users install with:
103
+ ```bash
104
+ pi install npm:pi-snippets
105
+ ```
106
+
107
+ ## License
108
+
109
+ MIT
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Pi Snippet Auto-Expander
3
+ *
4
+ * Zero-keystroke snippet expansion: type a trigger word and it expands
5
+ * instantly on the last keystroke — no delimiter, no confirmation.
6
+ *
7
+ * Expansion fires as soon as you finish typing a complete trigger word.
8
+ * The trigger must be at a word boundary (preceded by whitespace or at
9
+ * the start of input) to prevent mid-word false positives.
10
+ *
11
+ * Three-tier config (later sources override earlier ones):
12
+ * 1. Project (.pi/snippets.json, trusted projects only) ← highest
13
+ * 2. User global (~/.pi/agent/snippets.json)
14
+ * 3. Bundled defaults (snippets.json shipped with this package) ← lowest
15
+ */
16
+
17
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import {
19
+ CONFIG_DIR_NAME,
20
+ CustomEditor,
21
+ DynamicBorder,
22
+ type KeybindingsManager,
23
+ } from "@earendil-works/pi-coding-agent";
24
+ import { matchesKey, Key, truncateToWidth } from "@earendil-works/pi-tui";
25
+ import type { TUI, EditorTheme } from "@earendil-works/pi-tui";
26
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { homedir } from "node:os";
28
+ import { dirname, resolve } from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ // ── Snippet loading ────────────────────────────────────────────
32
+
33
+ interface LoadResult {
34
+ map: Map<string, string>;
35
+ errors: string[];
36
+ }
37
+
38
+ function loadSnippetMap(
39
+ extensionDir: string,
40
+ cwd: string,
41
+ isTrusted: boolean,
42
+ ): LoadResult {
43
+ const map = new Map<string, string>();
44
+ const errors: string[] = [];
45
+
46
+ function mergeFile(filePath: string, label: string): void {
47
+ if (!existsSync(filePath)) return;
48
+ try {
49
+ const raw = readFileSync(filePath, "utf-8");
50
+ const data = JSON.parse(raw);
51
+ if (typeof data !== "object" || data === null) {
52
+ errors.push(`${label}: expected a JSON object, got ${typeof data}`);
53
+ return;
54
+ }
55
+ for (const [key, value] of Object.entries(data)) {
56
+ if (typeof value === "string" && value.length > 0) {
57
+ map.set(key, value);
58
+ }
59
+ }
60
+ } catch (err: any) {
61
+ errors.push(`${label} (${filePath}): ${err.message}`);
62
+ }
63
+ }
64
+
65
+ // 1. Bundled defaults (lowest priority — loaded first, overridden by later sources)
66
+ mergeFile(resolve(extensionDir, "..", "snippets.json"), "bundled");
67
+
68
+ // 2. User global overrides
69
+ mergeFile(
70
+ resolve(homedir(), CONFIG_DIR_NAME, "agent", "snippets.json"),
71
+ "user-global",
72
+ );
73
+
74
+ // 3. Project overrides (highest priority — loaded last, wins over all) (only if trusted)
75
+ if (isTrusted) {
76
+ mergeFile(
77
+ resolve(cwd, CONFIG_DIR_NAME, "snippets.json"),
78
+ "project",
79
+ );
80
+ }
81
+
82
+ return { map, errors };
83
+ }
84
+
85
+ // ── Snippet Editor ─────────────────────────────────────────────
86
+
87
+ class SnippetEditor extends CustomEditor {
88
+ private snippets: Map<string, string>;
89
+ private innerEditor: CustomEditor | null;
90
+
91
+ constructor(
92
+ tui: TUI,
93
+ theme: EditorTheme,
94
+ kb: KeybindingsManager,
95
+ snippets: Map<string, string>,
96
+ innerEditor: CustomEditor | null = null,
97
+ ) {
98
+ super(tui, theme, kb);
99
+ this.snippets = snippets;
100
+ this.innerEditor = innerEditor;
101
+ }
102
+
103
+ handleInput(data: string): void {
104
+ // On every printable keystroke, check if we just completed a trigger word.
105
+ // The trigger must be preceded by whitespace or be at the start of input
106
+ // — this prevents mid-word false positives like "unpredictable" firing on "npr".
107
+ if (data.length === 1 && /[a-zA-Z0-9_]/.test(data)) {
108
+ const potentialText = this.getText() + data;
109
+
110
+ for (const [trigger, expansion] of this.snippets) {
111
+ if (potentialText.endsWith(trigger)) {
112
+ const prefixIndex = potentialText.length - trigger.length;
113
+ const isWordStart =
114
+ prefixIndex === 0 || /\s/.test(potentialText[prefixIndex - 1]);
115
+
116
+ if (isWordStart) {
117
+ // Expand immediately — no delimiter needed
118
+ this.setText(potentialText.slice(0, prefixIndex) + expansion);
119
+ return; // Don't let super insert the raw keystroke
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ // All other keys (backspace, arrows, enter, etc.) pass through normally.
126
+ // If we're wrapping another custom editor, delegate to it first.
127
+ if (this.innerEditor) {
128
+ this.innerEditor.handleInput(data);
129
+ } else {
130
+ super.handleInput(data);
131
+ }
132
+ }
133
+ }
134
+
135
+ // ── Module-level state ─────────────────────────────────────────
136
+ // Hoisted so the /add and /remove commands can update the editor live.
137
+
138
+ let currentSnippets: Map<string, string> | null = null;
139
+ let baseEditorFactory: ReturnType<
140
+ import("@earendil-works/pi-coding-agent").ExtensionUIContext["getEditorComponent"]
141
+ > = null;
142
+
143
+ function rebuildEditor(
144
+ ctx: { ui: { setEditorComponent: Function; getEditorComponent: Function } },
145
+ ): void {
146
+ ctx.ui.setEditorComponent((tui: TUI, theme: EditorTheme, kb: KeybindingsManager) => {
147
+ const inner = baseEditorFactory ? baseEditorFactory(tui, theme, kb) : null;
148
+ return new SnippetEditor(tui, theme, kb, currentSnippets!, inner);
149
+ });
150
+ }
151
+
152
+ // ── Persistent snippet storage ─────────────────────────────────
153
+
154
+ function userSnippetsPath(): string {
155
+ return resolve(homedir(), CONFIG_DIR_NAME, "agent", "snippets.json");
156
+ }
157
+
158
+ function writeUserSnippet(trigger: string, expansion: string): void {
159
+ const filePath = userSnippetsPath();
160
+ let data: Record<string, string> = {};
161
+
162
+ if (existsSync(filePath)) {
163
+ try {
164
+ const raw = readFileSync(filePath, "utf-8");
165
+ data = JSON.parse(raw);
166
+ if (typeof data !== "object" || data === null) {
167
+ data = {};
168
+ }
169
+ } catch {
170
+ data = {}; // corrupt file → start fresh
171
+ }
172
+ }
173
+
174
+ data[trigger] = expansion;
175
+
176
+ const dir = dirname(filePath);
177
+ if (!existsSync(dir)) {
178
+ mkdirSync(dir, { recursive: true });
179
+ }
180
+
181
+ writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
182
+ }
183
+
184
+ function removeUserSnippets(triggers: string[]): void {
185
+ const filePath = userSnippetsPath();
186
+ if (!existsSync(filePath)) return;
187
+
188
+ try {
189
+ const raw = readFileSync(filePath, "utf-8");
190
+ const data = JSON.parse(raw);
191
+ if (typeof data !== "object" || data === null) return;
192
+
193
+ for (const trigger of triggers) {
194
+ delete data[trigger];
195
+ }
196
+
197
+ writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
198
+ } catch {
199
+ // If we can't write, the in-memory state still reflects the removal
200
+ }
201
+ }
202
+
203
+ // ── Extension entry point ──────────────────────────────────────
204
+
205
+ export default function (pi: ExtensionAPI) {
206
+ // ── /add command ──
207
+ pi.registerCommand("add", {
208
+ description:
209
+ "Add a snippet (e.g. /add eml : user@email.com)",
210
+ handler: async (args, ctx) => {
211
+ if (!args || !args.includes(":")) {
212
+ ctx.ui.notify(
213
+ "Usage: /add <trigger> : <expansion>\n" +
214
+ " trigger — word that expands when typed\n" +
215
+ " expansion — text to replace it with",
216
+ "warning",
217
+ );
218
+ return;
219
+ }
220
+
221
+ const colonIndex = args.indexOf(":");
222
+ const trigger = args.slice(0, colonIndex).trim();
223
+ const expansion = args.slice(colonIndex + 1).trim();
224
+
225
+ if (!trigger) {
226
+ ctx.ui.notify("Snippet trigger cannot be empty.", "error");
227
+ return;
228
+ }
229
+ if (!expansion) {
230
+ ctx.ui.notify("Snippet expansion cannot be empty.", "error");
231
+ return;
232
+ }
233
+ if (/\s/.test(trigger)) {
234
+ ctx.ui.notify(
235
+ "Snippet trigger must be a single word (no spaces).",
236
+ "error",
237
+ );
238
+ return;
239
+ }
240
+
241
+ try {
242
+ // Persist to user-global snippets file
243
+ writeUserSnippet(trigger, expansion);
244
+
245
+ // Update in-memory map so it takes effect immediately
246
+ if (currentSnippets) {
247
+ currentSnippets.set(trigger, expansion);
248
+ }
249
+
250
+ // Rebuild the editor with the updated snippet map
251
+ rebuildEditor(ctx);
252
+
253
+ ctx.ui.notify(
254
+ `✓ Snippet added: "${trigger}" → "${expansion}"`,
255
+ "info",
256
+ );
257
+ } catch (err: any) {
258
+ ctx.ui.notify(`Failed to save snippet: ${err.message}`, "error");
259
+ }
260
+ },
261
+ });
262
+
263
+ // ── /remove command ──
264
+ pi.registerCommand("remove", {
265
+ description:
266
+ "Remove snippets interactively — choose from a list with multi-select",
267
+ handler: async (_args, ctx) => {
268
+ if (!currentSnippets || currentSnippets.size === 0) {
269
+ ctx.ui.notify("No snippets to remove.", "warning");
270
+ return;
271
+ }
272
+
273
+ const entries: { trigger: string; expansion: string }[] = [];
274
+ for (const [trigger, expansion] of currentSnippets) {
275
+ entries.push({ trigger, expansion });
276
+ }
277
+
278
+ const result = await ctx.ui.custom<number[] | null>(
279
+ (tui, theme, _kb, done) => {
280
+ let selected = 0;
281
+ const checked = new Set<number>();
282
+
283
+ const renderList = (w: number): string[] => {
284
+ const lines: string[] = [];
285
+ const maxPreview = Math.max(w - 14, 10);
286
+
287
+ // Top border
288
+ lines.push(
289
+ ...new DynamicBorder((s: string) => theme.fg("accent", s)).render(w),
290
+ );
291
+
292
+ // Title
293
+ lines.push(
294
+ truncateToWidth(
295
+ theme.fg("accent", theme.bold("Remove Snippets")),
296
+ w,
297
+ ),
298
+ );
299
+
300
+ // Items
301
+ for (let i = 0; i < entries.length; i++) {
302
+ const { trigger, expansion } = entries[i];
303
+ const cursor = i === selected ? theme.fg("accent", "▶") : " ";
304
+ const mark = checked.has(i)
305
+ ? theme.fg("success", "[✓]")
306
+ : theme.fg("dim", "[ ]");
307
+ const preview =
308
+ expansion.length > maxPreview
309
+ ? expansion.slice(0, maxPreview - 1) + "…"
310
+ : expansion;
311
+
312
+ lines.push(
313
+ truncateToWidth(
314
+ ` ${cursor} ${mark} ${theme.fg("accent", trigger)} → ${theme.fg("dim", preview)}`,
315
+ w,
316
+ ),
317
+ );
318
+ }
319
+
320
+ // Help bar
321
+ lines.push("");
322
+ lines.push(
323
+ truncateToWidth(
324
+ theme.fg(
325
+ "dim",
326
+ "↑↓ navigate Space toggle A select all Enter remove Esc cancel",
327
+ ),
328
+ w,
329
+ ),
330
+ );
331
+
332
+ // Bottom border
333
+ lines.push(
334
+ ...new DynamicBorder((s: string) => theme.fg("accent", s)).render(w),
335
+ );
336
+
337
+ return lines;
338
+ };
339
+
340
+ let cachedW = -1;
341
+ let cachedLines: string[] = [];
342
+
343
+ return {
344
+ render(w: number): string[] {
345
+ if (w !== cachedW) {
346
+ cachedW = w;
347
+ cachedLines = renderList(w);
348
+ }
349
+ return cachedLines;
350
+ },
351
+ invalidate(): void {
352
+ cachedW = -1;
353
+ },
354
+ handleInput(data: string): void {
355
+ let changed = false;
356
+ if (matchesKey(data, Key.up)) {
357
+ if (selected > 0) {
358
+ selected--;
359
+ changed = true;
360
+ }
361
+ } else if (matchesKey(data, Key.down)) {
362
+ if (selected < entries.length - 1) {
363
+ selected++;
364
+ changed = true;
365
+ }
366
+ } else if (matchesKey(data, Key.space)) {
367
+ if (checked.has(selected)) {
368
+ checked.delete(selected);
369
+ } else {
370
+ checked.add(selected);
371
+ }
372
+ changed = true;
373
+ } else if (data === "a" || data === "A") {
374
+ if (checked.size === entries.length) {
375
+ checked.clear();
376
+ } else {
377
+ for (let i = 0; i < entries.length; i++) checked.add(i);
378
+ }
379
+ changed = true;
380
+ } else if (matchesKey(data, Key.enter)) {
381
+ done(Array.from(checked));
382
+ return;
383
+ } else if (matchesKey(data, Key.escape)) {
384
+ done(null);
385
+ return;
386
+ }
387
+ if (changed) {
388
+ cachedW = -1;
389
+ tui.requestRender();
390
+ }
391
+ },
392
+ };
393
+ },
394
+ );
395
+
396
+ if (result === null) return; // cancelled
397
+
398
+ if (result.length === 0) {
399
+ ctx.ui.notify("No snippets selected for removal.", "info");
400
+ return;
401
+ }
402
+
403
+ // Remove selected snippets from in-memory map
404
+ const removed: string[] = [];
405
+ for (const idx of result) {
406
+ const { trigger } = entries[idx];
407
+ if (currentSnippets) currentSnippets.delete(trigger);
408
+ removed.push(trigger);
409
+ }
410
+
411
+ // Persist removal to user-global file
412
+ removeUserSnippets(removed);
413
+
414
+ // Rebuild editor with updated snippet map
415
+ rebuildEditor(ctx);
416
+
417
+ ctx.ui.notify(
418
+ `✓ Removed ${removed.length} snippet(s): ${removed.join(", ")}`,
419
+ "info",
420
+ );
421
+ },
422
+ });
423
+
424
+ // ── Lifecycle ──
425
+ pi.on("session_start", (_event, ctx) => {
426
+ // Resolve the extension's own directory to find bundled snippets.json
427
+ const extensionDir = dirname(fileURLToPath(import.meta.url));
428
+ const { map: snippets, errors } = loadSnippetMap(
429
+ extensionDir,
430
+ ctx.cwd,
431
+ ctx.isProjectTrusted(),
432
+ );
433
+
434
+ // Report any parse errors
435
+ for (const err of errors) {
436
+ ctx.ui.notify(`[snippets] ${err}`, "warning");
437
+ }
438
+
439
+ // Stash state so /add can update it live
440
+ currentSnippets = snippets;
441
+ baseEditorFactory = ctx.ui.getEditorComponent();
442
+ rebuildEditor(ctx);
443
+ });
444
+
445
+ // Clean up on shutdown: restore default editor
446
+ pi.on("session_shutdown", (_event, ctx) => {
447
+ currentSnippets = null;
448
+ baseEditorFactory = null;
449
+ ctx.ui.setEditorComponent(undefined);
450
+ });
451
+ }
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "pi-snippets",
3
+ "version": "1.0.0",
4
+ "description": "Zero-keystroke snippet auto-expander for pi — type a trigger word, hit space, and it expands",
5
+ "keywords": ["pi-package", "pi-snippets", "pi-extension", "snippets", "text-expander"],
6
+ "license": "MIT",
7
+ "author": "",
8
+ "pi": {
9
+ "extensions": ["./extensions"]
10
+ },
11
+ "peerDependencies": {
12
+ "@earendil-works/pi-coding-agent": "*",
13
+ "@earendil-works/pi-tui": "*"
14
+ }
15
+ }
package/snippets.json ADDED
@@ -0,0 +1 @@
1
+ {}