feat(pi): add prompt snippets extension
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Prompt Snippets — mix-and-match single-purpose prompt rules.
|
||||
*
|
||||
* Each snippet is a markdown file with frontmatter (name, description,
|
||||
* placement, order) stored in the `snippets/` directory next to this file.
|
||||
*
|
||||
* - Press alt+s or run /snippets to open the toggle menu (space: toggle,
|
||||
* tab: preview, enter: apply, esc: cancel). The menu is a bordered,
|
||||
* scrollable view.
|
||||
* - Active snippets appear as a widget above the editor, with prepend and
|
||||
* append groups visually distinguished.
|
||||
* - When a message is sent, active snippet bodies are prepended/appended to
|
||||
* the message text in order (prepend group sorted by `order` first, then
|
||||
* the typed text, then the append group sorted by `order`).
|
||||
* - Toggles reset to all-off after each send and at session start.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
||||
|
||||
interface Snippet {
|
||||
/** Filename, e.g. "concise.md" */
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
placement: "prepend" | "append";
|
||||
order: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const extensionDir = dirname(fileURLToPath(import.meta.url));
|
||||
const snippetsDir = join(extensionDir, "snippets");
|
||||
const WIDGET_ID = "prompt-snippets";
|
||||
|
||||
function parseSnippet(filename: string, raw: string): Snippet | null {
|
||||
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const meta: Record<string, string> = {};
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
const kv = line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*)$/);
|
||||
if (kv) meta[kv[1].toLowerCase()] = kv[2].trim().replace(/^["']|["']$/g, "");
|
||||
}
|
||||
|
||||
const body = match[2].trim();
|
||||
if (!body) return null;
|
||||
|
||||
const parsedOrder = Number.parseInt(meta.order ?? "", 10);
|
||||
return {
|
||||
id: filename,
|
||||
name: meta.name || filename.replace(/\.md$/i, ""),
|
||||
description: meta.description ?? "",
|
||||
placement: meta.placement === "prepend" ? "prepend" : "append",
|
||||
order: Number.isFinite(parsedOrder) ? parsedOrder : 9999,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
/** Load all snippets, sorted: prepend group first, append group last, each by (order, name). */
|
||||
function loadSnippets(): Snippet[] {
|
||||
if (!existsSync(snippetsDir)) return [];
|
||||
const snippets: Snippet[] = [];
|
||||
for (const file of readdirSync(snippetsDir)) {
|
||||
if (!file.toLowerCase().endsWith(".md")) continue;
|
||||
try {
|
||||
const snippet = parseSnippet(file, readFileSync(join(snippetsDir, file), "utf8"));
|
||||
if (snippet) snippets.push(snippet);
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
const byOrder = (a: Snippet, b: Snippet) => a.order - b.order || a.name.localeCompare(b.name);
|
||||
return [
|
||||
...snippets.filter((s) => s.placement === "prepend").sort(byOrder),
|
||||
...snippets.filter((s) => s.placement === "append").sort(byOrder),
|
||||
];
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Snippets last seen on disk (sorted). Refreshed whenever the menu opens or a message is sent.
|
||||
let snippets: Snippet[] = [];
|
||||
// Ids of currently toggled snippets. Resets to empty after each send and at session start.
|
||||
let enabled = new Set<string>();
|
||||
|
||||
function updateWidget(ctx: ExtensionContext) {
|
||||
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
||||
const active = snippets.filter((s) => enabled.has(s.id));
|
||||
const prepends = active.filter((s) => s.placement === "prepend");
|
||||
const appends = active.filter((s) => s.placement === "append");
|
||||
|
||||
if (prepends.length === 0 && appends.length === 0) {
|
||||
ctx.ui.setWidget(WIDGET_ID, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const theme = ctx.ui.theme;
|
||||
const lines: string[] = [];
|
||||
if (prepends.length > 0) {
|
||||
lines.push(theme.fg("accent", `↑ prepend: ${prepends.map((s) => s.name).join(" · ")}`));
|
||||
}
|
||||
if (appends.length > 0) {
|
||||
lines.push(theme.fg("warning", `↓ append: ${appends.map((s) => s.name).join(" · ")}`));
|
||||
}
|
||||
ctx.ui.setWidget(WIDGET_ID, lines);
|
||||
}
|
||||
|
||||
async function openMenu(ctx: ExtensionContext) {
|
||||
if (ctx.mode !== "tui") {
|
||||
ctx.ui.notify("Snippet menu requires interactive mode", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
snippets = loadSnippets();
|
||||
// Drop toggles for snippets that no longer exist on disk.
|
||||
enabled = new Set([...enabled].filter((id) => snippets.some((s) => s.id === id)));
|
||||
|
||||
if (snippets.length === 0) {
|
||||
ctx.ui.notify(`No snippets found in ${snippetsDir}`, "warning");
|
||||
updateWidget(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Working copy; only committed to `enabled` on confirm.
|
||||
const working = new Set(enabled);
|
||||
|
||||
const confirmed = await ctx.ui.custom<boolean>((tui, theme, _keybindings, done) => {
|
||||
const prepends = snippets.filter((s) => s.placement === "prepend");
|
||||
const appends = snippets.filter((s) => s.placement === "append");
|
||||
const items = [...prepends, ...appends];
|
||||
|
||||
let mode: "list" | "preview" = "list";
|
||||
let cursor = 0;
|
||||
let listScroll = 0;
|
||||
let previewScroll = 0;
|
||||
|
||||
const itemRow = (snippet: Snippet, idx: number, width: number): string => {
|
||||
const pointer = idx === cursor ? theme.fg("accent", "> ") : " ";
|
||||
const checkbox = working.has(snippet.id) ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
|
||||
const desc = snippet.description ? theme.fg("dim", ` — ${snippet.description}`) : "";
|
||||
return truncateToWidth(`${pointer}${checkbox} ${theme.bold(snippet.name)}${desc}`, width);
|
||||
};
|
||||
|
||||
/** List rows with the item index each row corresponds to (null for headers/blanks). */
|
||||
const buildListRows = (width: number): { text: string; itemIndex: number | null }[] => {
|
||||
const rows: { text: string; itemIndex: number | null }[] = [];
|
||||
rows.push({ text: theme.fg("dim", "↑ PREPEND — added before your message"), itemIndex: null });
|
||||
prepends.forEach((s, i) => rows.push({ text: itemRow(s, i, width), itemIndex: i }));
|
||||
rows.push({ text: "", itemIndex: null });
|
||||
rows.push({ text: theme.fg("dim", "↓ APPEND — added after your message"), itemIndex: null });
|
||||
appends.forEach((s, i) => rows.push({ text: itemRow(s, prepends.length + i, width), itemIndex: prepends.length + i }));
|
||||
return rows;
|
||||
};
|
||||
|
||||
const buildPreviewRows = (snippet: Snippet, width: number): string[] => {
|
||||
const rows: string[] = [];
|
||||
rows.push(truncateToWidth(theme.bold(snippet.name), width));
|
||||
rows.push(truncateToWidth(theme.fg("dim", `${snippet.placement} · order ${snippet.order} · ${snippet.id}`), width));
|
||||
rows.push(theme.fg("dim", "─".repeat(Math.min(width, 40))));
|
||||
for (const line of snippet.body.split("\n")) {
|
||||
for (const wrapped of wrapTextWithAnsi(line, width)) {
|
||||
rows.push(truncateToWidth(wrapped, width));
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slice `lines` to a scrollable viewport of at most `maxView` lines,
|
||||
* reserving indicator slots when clipped. Returns the visible lines
|
||||
* plus the clamped scroll position. When `focusRow` is given, scrolls
|
||||
* so it stays visible.
|
||||
*/
|
||||
const viewport = (
|
||||
lines: string[],
|
||||
scroll: number,
|
||||
maxView: number,
|
||||
focusRow?: number,
|
||||
): { out: string[]; scroll: number } => {
|
||||
const clipped = lines.length > maxView;
|
||||
const view = clipped ? Math.max(1, maxView - 2) : maxView;
|
||||
|
||||
let s = Math.min(Math.max(0, scroll), Math.max(0, lines.length - view));
|
||||
if (focusRow !== undefined) {
|
||||
if (focusRow < s) s = focusRow;
|
||||
else if (focusRow >= s + view) s = focusRow - view + 1;
|
||||
}
|
||||
|
||||
const visible = lines.slice(s, s + view);
|
||||
if (!clipped) return { out: visible, scroll: s };
|
||||
|
||||
const above = s;
|
||||
const below = lines.length - (s + view);
|
||||
return {
|
||||
out: [
|
||||
above > 0 ? theme.fg("dim", ` ↑ ${above} more`) : "",
|
||||
...visible,
|
||||
below > 0 ? theme.fg("dim", ` ↓ ${below} more`) : "",
|
||||
],
|
||||
scroll: s,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
render(width: number): string[] {
|
||||
// Reserve lines for: top border, title, blank, blank, hints, bottom border.
|
||||
const maxView = Math.max(5, tui.terminal.rows - 10);
|
||||
|
||||
let content: string[];
|
||||
let title: string;
|
||||
let hints: string;
|
||||
if (mode === "list") {
|
||||
const rows = buildListRows(width);
|
||||
const cursorRow = rows.findIndex((r) => r.itemIndex === cursor);
|
||||
const v = viewport(rows.map((r) => r.text), listScroll, maxView, cursorRow);
|
||||
content = v.out;
|
||||
listScroll = v.scroll;
|
||||
title = "Prompt snippets";
|
||||
hints = "↑↓ navigate • Space toggle • Tab preview • Enter apply • Esc cancel";
|
||||
} else {
|
||||
const snippet = items[cursor];
|
||||
const rows = buildPreviewRows(snippet, width);
|
||||
const v = viewport(rows, previewScroll, maxView);
|
||||
content = v.out;
|
||||
previewScroll = v.scroll;
|
||||
title = `Preview: ${snippet.name}`;
|
||||
hints = "↑↓ scroll • Tab/Esc back";
|
||||
}
|
||||
|
||||
return [
|
||||
theme.fg("accent", "─".repeat(width)),
|
||||
truncateToWidth(` ${theme.fg("accent", theme.bold(title))}`, width),
|
||||
"",
|
||||
...content,
|
||||
"",
|
||||
truncateToWidth(theme.fg("dim", ` ${hints}`), width),
|
||||
theme.fg("accent", "─".repeat(width)),
|
||||
];
|
||||
},
|
||||
invalidate() {},
|
||||
handleInput(data: string) {
|
||||
if (mode === "list") {
|
||||
if (matchesKey(data, Key.up)) {
|
||||
cursor = (cursor - 1 + items.length) % items.length;
|
||||
tui.requestRender();
|
||||
} else if (matchesKey(data, Key.down)) {
|
||||
cursor = (cursor + 1) % items.length;
|
||||
tui.requestRender();
|
||||
} else if (matchesKey(data, Key.space)) {
|
||||
const id = items[cursor].id;
|
||||
if (working.has(id)) working.delete(id);
|
||||
else working.add(id);
|
||||
tui.requestRender();
|
||||
} else if (matchesKey(data, Key.tab)) {
|
||||
mode = "preview";
|
||||
previewScroll = 0;
|
||||
tui.requestRender();
|
||||
} else if (matchesKey(data, Key.enter)) {
|
||||
done(true);
|
||||
} else if (matchesKey(data, Key.escape)) {
|
||||
done(false);
|
||||
}
|
||||
} else {
|
||||
if (matchesKey(data, Key.up)) {
|
||||
previewScroll--;
|
||||
tui.requestRender();
|
||||
} else if (matchesKey(data, Key.down)) {
|
||||
previewScroll++;
|
||||
tui.requestRender();
|
||||
} else if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) {
|
||||
mode = "list";
|
||||
tui.requestRender();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
enabled = working;
|
||||
}
|
||||
updateWidget(ctx);
|
||||
}
|
||||
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
enabled = new Set();
|
||||
snippets = loadSnippets();
|
||||
if (!existsSync(snippetsDir)) mkdirSync(snippetsDir, { recursive: true });
|
||||
updateWidget(ctx);
|
||||
});
|
||||
|
||||
pi.on("input", async (event, ctx) => {
|
||||
if (enabled.size === 0) return; // continue unchanged
|
||||
|
||||
snippets = loadSnippets();
|
||||
const active = snippets.filter((s) => enabled.has(s.id));
|
||||
enabled = new Set();
|
||||
updateWidget(ctx);
|
||||
|
||||
if (active.length === 0) return; // all toggled snippets vanished from disk
|
||||
|
||||
const prependBodies = active.filter((s) => s.placement === "prepend").map((s) => s.body);
|
||||
const appendBodies = active.filter((s) => s.placement === "append").map((s) => s.body);
|
||||
return {
|
||||
action: "transform",
|
||||
text: [...prependBodies, event.text, ...appendBodies].join("\n\n"),
|
||||
};
|
||||
});
|
||||
|
||||
pi.registerShortcut("alt+x", {
|
||||
description: "Toggle prompt snippets",
|
||||
handler: async (ctx) => {
|
||||
await openMenu(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("snippets", {
|
||||
description: "Open the prompt snippet toggle menu",
|
||||
handler: async (_args, ctx) => {
|
||||
await openMenu(ctx);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user