@mrclrchtr/supi-extras 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.
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Prompt stash extension for pi.
3
+ *
4
+ * Provides `Alt+S` to stash the current editor text and `/supi-stash` for
5
+ * browsing and managing stashed drafts with a keyboard-driven overlay picker.
6
+ *
7
+ * Stashes are persisted to ~/.pi/agent/supi/prompt-stash.json so they survive
8
+ * pi restarts. On I/O errors the stash falls back to in-memory-only operation.
9
+ */
10
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
15
+ import { Container, type SelectItem, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
16
+ import { copyToClipboard } from "./clipboard.ts";
17
+
18
+ /** In-memory stash entry. */
19
+ interface Stash {
20
+ id: string;
21
+ name: string;
22
+ text: string;
23
+ createdAt: number;
24
+ }
25
+
26
+ /** Storage directory relative to the user's home directory. */
27
+ const STORAGE_RELATIVE_DIR = ".pi/agent/supi";
28
+ const STASH_FILE = "prompt-stash.json";
29
+
30
+ /** Resolve the absolute path to the stash persistence file. */
31
+ function getStashFilePath(): string {
32
+ return join(homedir(), STORAGE_RELATIVE_DIR, STASH_FILE);
33
+ }
34
+
35
+ /** Attempt to parse stashes from a raw JSON value. Returns null on failure. */
36
+ function parseStashEntries(raw: unknown): Map<string, Stash> | null {
37
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
38
+ const map = new Map<string, Stash>();
39
+ for (const [id, entry] of Object.entries(raw)) {
40
+ if (entry && typeof entry === "object") {
41
+ map.set(id, entry as Stash);
42
+ }
43
+ }
44
+ return map.size > 0 ? map : null;
45
+ }
46
+
47
+ /**
48
+ * Load stashes from disk. Returns an empty map when the file does not exist
49
+ * or cannot be parsed — the stash degrades gracefully to in-memory-only.
50
+ */
51
+ function loadStashesFromDisk(): Map<string, Stash> {
52
+ let content: string;
53
+ try {
54
+ content = readFileSync(getStashFilePath(), "utf-8");
55
+ } catch (err) {
56
+ // ENOENT on first run is expected — warn on all other errors
57
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
58
+ // biome-ignore lint/suspicious/noConsole: deliberate degradation warning
59
+ console.warn("[supi-extras] Failed to load prompt stash from disk, starting fresh:", err);
60
+ }
61
+ return new Map();
62
+ }
63
+
64
+ let parsed: unknown;
65
+ try {
66
+ parsed = JSON.parse(content);
67
+ } catch (err) {
68
+ // biome-ignore lint/suspicious/noConsole: deliberate degradation warning
69
+ console.warn("[supi-extras] Failed to parse prompt stash file, starting fresh:", err);
70
+ return new Map();
71
+ }
72
+
73
+ const root =
74
+ parsed && typeof parsed === "object" && !Array.isArray(parsed)
75
+ ? (parsed as Record<string, unknown>)
76
+ : null;
77
+ if (!root) return new Map();
78
+
79
+ return parseStashEntries(root.stashes) ?? new Map();
80
+ }
81
+
82
+ /**
83
+ * Save stashes to disk. Silently ignores write errors so a read-only
84
+ * filesystem never breaks the stash in-memory.
85
+ */
86
+ function saveStashesToDisk(stashes: Map<string, Stash>): void {
87
+ try {
88
+ const filePath = getStashFilePath();
89
+ mkdirSync(dirname(filePath), { recursive: true });
90
+
91
+ const data: Record<string, Stash> = {};
92
+ for (const [id, stash] of stashes) {
93
+ data[id] = stash;
94
+ }
95
+
96
+ writeFileSync(filePath, `${JSON.stringify({ stashes: data }, null, 2)}\n`, "utf-8");
97
+ } catch (err) {
98
+ // biome-ignore lint/suspicious/noConsole: deliberate degradation warning
99
+ console.warn(
100
+ "[supi-extras] Failed to persist prompt stash to disk, continuing in-memory:",
101
+ err,
102
+ );
103
+ }
104
+ }
105
+
106
+ /** Persisted stash store. Loaded from disk at module init. */
107
+ const STASHES = loadStashesFromDisk();
108
+
109
+ /** Reset stashes — intended for tests only. Also clears the persisted file. */
110
+ export function _resetStashes(): void {
111
+ STASHES.clear();
112
+ saveStashesToDisk(STASHES);
113
+ }
114
+
115
+ /** Generate a unique stash id. */
116
+ function generateId(): string {
117
+ return `stash-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
118
+ }
119
+
120
+ /**
121
+ * Derive a default name from the first line of the prompt text.
122
+ * Falls back to "Untitled" for empty input.
123
+ */
124
+ function generateName(text: string): string {
125
+ const firstLine = text.split("\n")[0]?.trim() ?? "";
126
+ if (firstLine.length > 0 && firstLine.length <= 40) return firstLine;
127
+ if (firstLine.length > 40) return `${firstLine.slice(0, 37)}...`;
128
+ return "Untitled";
129
+ }
130
+
131
+ /** Result returned from the stash picker overlay. */
132
+ type StashPickerResult =
133
+ | { action: "restore" | "copy"; stash: Stash }
134
+ | { action: "cleared" }
135
+ | null;
136
+
137
+ /**
138
+ * Build the custom overlay component for the stash picker.
139
+ *
140
+ * Returns a promise that resolves when the user picks an action
141
+ * (restore, copy, clear-all) or cancels (null).
142
+ *
143
+ * The overlay stays open on single-delete (`d`) and refreshes the list
144
+ * in-place. Close actions: Enter (restore), `c` (copy), `D` (clear-all),
145
+ * Escape (cancel).
146
+ */
147
+ function showStashPickerOverlay(ctx: ExtensionContext): Promise<StashPickerResult> {
148
+ return ctx.ui.custom<StashPickerResult>(
149
+ (tui, theme, _kb, done) => {
150
+ const container = new Container();
151
+ let selectList: SelectList;
152
+
153
+ function buildItems(): SelectItem[] {
154
+ return Array.from(STASHES.values())
155
+ .sort((a, b) => b.createdAt - a.createdAt)
156
+ .map((s) => ({ value: s.id, label: s.name }));
157
+ }
158
+
159
+ function createSelectList(items: SelectItem[], selectedIndex?: number): SelectList {
160
+ const list = new SelectList(items, Math.min(items.length, 10), {
161
+ selectedPrefix: (text: string) => theme.fg("accent", text),
162
+ selectedText: (text: string) => theme.fg("accent", text),
163
+ description: (text: string) => theme.fg("muted", text),
164
+ scrollInfo: (text: string) => theme.fg("dim", text),
165
+ noMatch: (text: string) => theme.fg("warning", text),
166
+ });
167
+
168
+ list.onSelect = (item) => {
169
+ const stash = STASHES.get(item.value);
170
+ if (stash) done({ action: "restore", stash });
171
+ };
172
+ list.onCancel = () => done(null);
173
+
174
+ if (selectedIndex !== undefined) {
175
+ list.setSelectedIndex(selectedIndex);
176
+ }
177
+ return list;
178
+ }
179
+
180
+ function refresh(preferredIndex?: number) {
181
+ const items = buildItems();
182
+ if (items.length === 0) {
183
+ done(null);
184
+ return;
185
+ }
186
+
187
+ container.clear();
188
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
189
+ container.addChild(new Text(theme.fg("accent", theme.bold(" Stashed Prompts"))));
190
+ container.addChild(new Spacer(1));
191
+
192
+ selectList = createSelectList(items, preferredIndex);
193
+ container.addChild(selectList);
194
+
195
+ container.addChild(new Spacer(1));
196
+ container.addChild(
197
+ new Text(theme.fg("dim", " c:copy d:delete D:clear-all enter:restore esc:cancel")),
198
+ );
199
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
200
+
201
+ tui.requestRender();
202
+ }
203
+
204
+ // Initial build
205
+ refresh();
206
+
207
+ return {
208
+ render(width: number) {
209
+ return container.render(width);
210
+ },
211
+ invalidate() {
212
+ container.invalidate();
213
+ },
214
+ handleInput(data: string) {
215
+ const item = selectList.getSelectedItem();
216
+ if (!item) {
217
+ selectList.handleInput(data);
218
+ tui.requestRender();
219
+ return;
220
+ }
221
+
222
+ const stash = STASHES.get(item.value);
223
+ if (!stash) {
224
+ selectList.handleInput(data);
225
+ tui.requestRender();
226
+ return;
227
+ }
228
+
229
+ if (data === "c") {
230
+ done({ action: "copy", stash });
231
+ return;
232
+ }
233
+
234
+ if (data === "d") {
235
+ const currentItems = buildItems();
236
+ const oldIndex = currentItems.findIndex((i) => i.value === item.value);
237
+ STASHES.delete(stash.id);
238
+ saveStashesToDisk(STASHES);
239
+ refresh(oldIndex);
240
+ return;
241
+ }
242
+
243
+ if (data === "D") {
244
+ STASHES.clear();
245
+ saveStashesToDisk(STASHES);
246
+ done({ action: "cleared" });
247
+ return;
248
+ }
249
+
250
+ selectList.handleInput(data);
251
+ tui.requestRender();
252
+ },
253
+ };
254
+ },
255
+ { overlay: true },
256
+ );
257
+ }
258
+
259
+ /** Register the prompt-stash shortcuts and commands. */
260
+ export default function promptStash(pi: ExtensionAPI) {
261
+ pi.registerShortcut("alt+s", {
262
+ description: "Stash current editor text",
263
+ handler: async (ctx) => {
264
+ const text = ctx.ui.getEditorText();
265
+ if (!text.trim()) {
266
+ ctx.ui.notify("Editor is empty — nothing to stash", "warning");
267
+ return;
268
+ }
269
+
270
+ const defaultName = generateName(text);
271
+ const name = await ctx.ui.input("Stash name:", defaultName);
272
+ if (name === undefined) return;
273
+
274
+ const id = generateId();
275
+ STASHES.set(id, {
276
+ id,
277
+ name: name || defaultName,
278
+ text,
279
+ createdAt: Date.now(),
280
+ });
281
+ saveStashesToDisk(STASHES);
282
+ ctx.ui.setEditorText("");
283
+ ctx.ui.notify(`Stashed: "${name || defaultName}"`, "info");
284
+ },
285
+ });
286
+
287
+ pi.registerCommand("supi-stash", {
288
+ description: "Browse, restore, copy, delete, or clear all stashed prompts",
289
+ handler: async (_args, ctx) => {
290
+ if (STASHES.size === 0) {
291
+ ctx.ui.notify("No stashed prompts", "info");
292
+ return;
293
+ }
294
+
295
+ const result = await showStashPickerOverlay(ctx);
296
+ if (!result) return;
297
+
298
+ if (result.action === "cleared") {
299
+ ctx.ui.notify("All stashes cleared", "info");
300
+ return;
301
+ }
302
+
303
+ if (result.action === "restore") {
304
+ ctx.ui.setEditorText(result.stash.text);
305
+ ctx.ui.notify(`Restored: "${result.stash.name}"`, "info");
306
+ return;
307
+ }
308
+
309
+ // copy
310
+ const ok = await copyToClipboard(result.stash.text, ctx.cwd, pi);
311
+ ctx.ui.notify(
312
+ ok ? `Copied "${result.stash.name}" to clipboard` : "Failed to copy to clipboard",
313
+ ok ? "info" : "error",
314
+ );
315
+ },
316
+ });
317
+
318
+ pi.on("session_shutdown", () => {
319
+ saveStashesToDisk(STASHES);
320
+ });
321
+ }
@@ -0,0 +1,122 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { fuzzyFilter } from "@earendil-works/pi-tui";
3
+
4
+ /**
5
+ * Extension: `$` as a shortcut prefix for skills.
6
+ *
7
+ * - `$agent-browser` expands to `/skill:agent-browser`
8
+ * - Autocomplete triggers on `$` showing only skill names
9
+ * - Works anywhere in the prompt (after space or at start)
10
+ */
11
+
12
+ const DELIMITERS = new Set([" ", "\t", "\n"]);
13
+
14
+ /** Find the `$token` at the cursor, or null if not in one. */
15
+ function extractDollarPrefix(textBeforeCursor: string): string | null {
16
+ // Walk backwards to find the start of the current token
17
+ for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
18
+ const char = textBeforeCursor[i];
19
+ if (char && DELIMITERS.has(char)) {
20
+ // Hit a delimiter — the token starts at i+1
21
+ const token = textBeforeCursor.slice(i + 1);
22
+ return token.startsWith("$") ? token : null;
23
+ }
24
+ }
25
+ // Reached start of line
26
+ return textBeforeCursor.startsWith("$") ? textBeforeCursor : null;
27
+ }
28
+
29
+ // ── Extension entry point ─────────────────────────────────────────
30
+
31
+ /**
32
+ * Register `$skill-name` → `/skill:skill-name` expansion and autocomplete.
33
+ *
34
+ * ## Behavior gotchas
35
+ *
36
+ * - Installed skill names are snapshotted at `session_start` via
37
+ * `pi.getCommands()`; after adding or removing skills, use `/reload` or
38
+ * start a new session before testing expansion behavior.
39
+ * - Outside `$...` tokens, autocomplete must delegate back to the current
40
+ * provider so built-in completion and file completion continue to work.
41
+ *
42
+ * ## Testing
43
+ *
44
+ * If behavior changes, test both:
45
+ * - expansion inside `$...` tokens
46
+ * - normal autocomplete everywhere else
47
+ */
48
+ export default function (pi: ExtensionAPI) {
49
+ let skillNames: string[] = [];
50
+ let skillCommands: { name: string; description?: string }[] = [];
51
+
52
+ pi.on("session_start", (_event, ctx) => {
53
+ const commands = pi.getCommands();
54
+ skillCommands = commands
55
+ .filter((c) => c.source === "skill")
56
+ .map((c) => ({
57
+ name: c.name.replace(/^skill:/, ""),
58
+ description: c.description,
59
+ }));
60
+ skillNames = skillCommands.map((c) => c.name);
61
+
62
+ // Stack skill autocomplete on top of the built-in provider.
63
+ // addAutocompleteProvider takes a wrapper callback: (current) => provider.
64
+ ctx.ui.addAutocompleteProvider((current) => ({
65
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
66
+ const textBeforeCursor = (lines[cursorLine] || "").slice(0, cursorCol);
67
+ const dollarPrefix = extractDollarPrefix(textBeforeCursor);
68
+
69
+ if (!dollarPrefix || dollarPrefix.includes(" ")) {
70
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
71
+ }
72
+
73
+ const query = dollarPrefix.slice(1);
74
+ const items = skillCommands.map((c) => ({
75
+ name: c.name,
76
+ description: c.description,
77
+ }));
78
+ const filtered = fuzzyFilter(items, query, (i) => i.name).map((i) => ({
79
+ value: i.name,
80
+ label: i.name,
81
+ ...(i.description && { description: i.description }),
82
+ }));
83
+ return filtered.length
84
+ ? { items: filtered, prefix: dollarPrefix }
85
+ : current.getSuggestions(lines, cursorLine, cursorCol, options);
86
+ },
87
+ // biome-ignore lint/complexity/useMaxParams: AutocompleteProvider interface
88
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
89
+ if (prefix.startsWith("$")) {
90
+ const line = lines[cursorLine] || "";
91
+ const before = line.slice(0, cursorCol - prefix.length);
92
+ const after = line.slice(cursorCol);
93
+ const newLine = `${before}$${item.value} ${after}`;
94
+ return {
95
+ lines: [...lines.slice(0, cursorLine), newLine, ...lines.slice(cursorLine + 1)],
96
+ cursorLine,
97
+ cursorCol: before.length + 1 + item.value.length + 1,
98
+ };
99
+ }
100
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
101
+ },
102
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
103
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
104
+ },
105
+ }));
106
+ });
107
+
108
+ // Transform $skill-name → /skill:skill-name before agent processing
109
+ pi.on("input", (event) => {
110
+ const text = event.text.trim();
111
+
112
+ // Find all $skill-name tokens and replace them
113
+ const transformed = text.replace(/(?:^|(?<=\s))\$([a-z0-9][-a-z0-9]*)/g, (_match, name) => {
114
+ return skillNames.includes(name) ? `/skill:${name}` : _match;
115
+ });
116
+
117
+ if (transformed !== text) {
118
+ return { action: "transform" as const, text: transformed };
119
+ }
120
+ return { action: "continue" as const };
121
+ });
122
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Terminal tab title spinner for pi.
3
+ *
4
+ * Shows a braille spinner in the terminal tab title while the agent is working.
5
+ * Recomputes the base title dynamically on every tick so that `/name` renames
6
+ * and cwd changes are picked up automatically.
7
+ *
8
+ * Also activates during long-running extension tasks such as `supi-review`.
9
+ * When the agent turn ends, a ✓ symbol is shown persistently until the next
10
+ * agent starts or the session shuts down.
11
+ */
12
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { formatTitle, signalDone } from "@mrclrchtr/supi-core";
14
+
15
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
16
+
17
+ export default function tabSpinner(pi: ExtensionAPI) {
18
+ let timer: ReturnType<typeof setInterval> | null = null;
19
+ let frame = 0;
20
+ let activeCount = 0;
21
+ let hasActiveAgent = false;
22
+ let askUserActive = 0;
23
+ let currentCtx: ExtensionContext | undefined;
24
+
25
+ /** Build the current base title from session name and cwd. */
26
+ function title() {
27
+ return formatTitle(pi.getSessionName(), currentCtx?.cwd);
28
+ }
29
+
30
+ /** Restore the base title immediately. */
31
+ function stop() {
32
+ if (timer) {
33
+ clearInterval(timer);
34
+ timer = null;
35
+ }
36
+ frame = 0;
37
+ if (currentCtx) {
38
+ currentCtx.ui.setTitle(title());
39
+ }
40
+ }
41
+
42
+ /** Show the ✓ done symbol in the title and play the terminal bell. */
43
+ function showDone() {
44
+ if (timer) {
45
+ clearInterval(timer);
46
+ timer = null;
47
+ }
48
+ frame = 0;
49
+ if (currentCtx) {
50
+ signalDone(currentCtx, title());
51
+ }
52
+ }
53
+
54
+ /** Start the spinner interval. Overwrites any ✓ shown. */
55
+ function start() {
56
+ if (timer) return;
57
+ if (!currentCtx) return;
58
+ timer = setInterval(() => {
59
+ const icon = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
60
+ currentCtx?.ui.setTitle(`${icon} ${title()}`);
61
+ frame++;
62
+ }, 80);
63
+ }
64
+
65
+ function increment(ctx: ExtensionContext) {
66
+ currentCtx = ctx;
67
+ activeCount++;
68
+ if (activeCount === 1) start();
69
+ }
70
+
71
+ /** Decrement count for supi:working tasks — restores title when idle. */
72
+ function decrement() {
73
+ const floor = hasActiveAgent ? 1 : 0;
74
+ activeCount = Math.max(floor, activeCount - 1);
75
+ if (activeCount === 0) stop();
76
+ }
77
+
78
+ /** Decrement count for agent turns — shows ✓ when idle. */
79
+ function agentEnded() {
80
+ activeCount = Math.max(0, activeCount - 1);
81
+ if (activeCount === 0) showDone();
82
+ }
83
+
84
+ pi.on("agent_start", async (_event, ctx) => {
85
+ hasActiveAgent = true;
86
+ increment(ctx);
87
+ });
88
+
89
+ pi.on("agent_end", async (_event, _ctx) => {
90
+ hasActiveAgent = false;
91
+ agentEnded();
92
+ });
93
+
94
+ pi.on("session_shutdown", async (_event, ctx) => {
95
+ activeCount = 0;
96
+ currentCtx = ctx;
97
+ stop();
98
+ });
99
+
100
+ pi.events.on("supi:working:start", () => {
101
+ if (currentCtx) increment(currentCtx);
102
+ });
103
+
104
+ pi.events.on("supi:working:end", () => {
105
+ decrement();
106
+ });
107
+
108
+ pi.events.on("supi:ask-user:start", () => {
109
+ askUserActive++;
110
+ // Pause the spinner so ask_user's attention title (set via signalWaiting)
111
+ // is visible to the user instead of being overwritten on the next tick.
112
+ if (timer) {
113
+ clearInterval(timer);
114
+ timer = null;
115
+ }
116
+ });
117
+
118
+ pi.events.on("supi:ask-user:end", () => {
119
+ askUserActive = Math.max(0, askUserActive - 1);
120
+ if (askUserActive === 0 && activeCount > 0) {
121
+ // Resume the spinner if the agent (or background work) is still running.
122
+ start();
123
+ }
124
+ });
125
+ }