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 +109 -0
- package/extensions/index.ts +451 -0
- package/package.json +15 -0
- package/snippets.json +1 -0
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
|
+
{}
|