From 5584c474f0d32ad699deaa5a63814242ea9dc571 Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 26 Aug 2026 19:13:23 -0400 Subject: [PATCH] feat(pi): add prompt snippets extension --- config/pi-links.json | 1 + extensions/prompt-snippets/README.md | 48 +++ extensions/prompt-snippets/index.ts | 325 ++++++++++++++++++ .../prompt-snippets/snippets/ask-questions.md | 8 + .../snippets/delegate-exploration.md | 7 + .../snippets/diagnose-report.md | 7 + .../snippets/orchestrator-mode.md | 7 + .../snippets/session-kickoff.md | 7 + .../snippets/verify-not-assume.md | 7 + 9 files changed, 417 insertions(+) create mode 100644 extensions/prompt-snippets/README.md create mode 100644 extensions/prompt-snippets/index.ts create mode 100644 extensions/prompt-snippets/snippets/ask-questions.md create mode 100644 extensions/prompt-snippets/snippets/delegate-exploration.md create mode 100644 extensions/prompt-snippets/snippets/diagnose-report.md create mode 100644 extensions/prompt-snippets/snippets/orchestrator-mode.md create mode 100644 extensions/prompt-snippets/snippets/session-kickoff.md create mode 100644 extensions/prompt-snippets/snippets/verify-not-assume.md diff --git a/config/pi-links.json b/config/pi-links.json index ebdc192..5cebd68 100644 --- a/config/pi-links.json +++ b/config/pi-links.json @@ -24,6 +24,7 @@ "globalSymlinks": { "effort-controls": "extensions/effort-controls", "pi-workflow": "extensions/pi-workflow", + "prompt-snippets": "extensions/prompt-snippets", "update": "extensions/update" }, "globalLocalPackages": {} diff --git a/extensions/prompt-snippets/README.md b/extensions/prompt-snippets/README.md new file mode 100644 index 0000000..4ffc596 --- /dev/null +++ b/extensions/prompt-snippets/README.md @@ -0,0 +1,48 @@ +# Prompt Snippets + +Mix-and-match single-purpose prompt rules that are prepended or appended to +your message when you send it. Unlike skills, each snippet is a tiny, +standalone instruction — toggle exactly the ones you want per message. + +## Usage + +- Press **alt+s** or run **/snippets** to open the toggle menu. + - `up`/`down` to navigate, `space` to toggle, `enter` to apply, `esc` to cancel. + - `tab` previews the highlighted snippet (name, placement, order, filename, + and full body; `up`/`down` scroll long bodies). `tab` or `esc` returns to + the list with your cursor position preserved. + - The menu is framed with top/bottom border lines and scrolls when the list + exceeds the viewport (max height adapts to your terminal), with + `↑ n more` / `↓ n more` indicators when clipped. +- Active snippets show up as a widget above the editor: + - `↑ prepend: ...` (accent color) — inserted before your message + - `↓ append: ...` (warning color) — inserted after your message +- When you send a message, active snippet bodies are merged into the message + text: prepend group (sorted by `order`) → your text → append group (sorted + by `order`), separated by blank lines. +- Toggles reset to **all off** after each send and at session start. + +## Snippet files + +Snippets live in `snippets/` next to `index.ts` — one markdown file each, +with frontmatter: + +```markdown +--- +name: Concise +description: Keep answers short and to the point +placement: prepend +order: 10 +--- +Keep your response concise. Skip preamble and unnecessary explanation. +``` + +| Field | Required | Notes | +|---|---|---| +| `name` | no | Display name; defaults to the filename without `.md` | +| `description` | no | Shown next to the name in the toggle menu | +| `placement` | no | `prepend` or `append` (default: `append`) | +| `order` | no | Number; sorts snippets within their group, in the menu and in the applied text (default: `9999`, ties broken by name) | + +Files are re-scanned every time the menu opens and every time a message is +sent, so edits take effect immediately — no `/reload` needed. diff --git a/extensions/prompt-snippets/index.ts b/extensions/prompt-snippets/index.ts new file mode 100644 index 0000000..51beb2d --- /dev/null +++ b/extensions/prompt-snippets/index.ts @@ -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 = {}; + 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(); + + 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((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); + }, + }); +} diff --git a/extensions/prompt-snippets/snippets/ask-questions.md b/extensions/prompt-snippets/snippets/ask-questions.md new file mode 100644 index 0000000..883b6ec --- /dev/null +++ b/extensions/prompt-snippets/snippets/ask-questions.md @@ -0,0 +1,8 @@ +--- +name: Ask questions +description: Ask questions to reach a shared understanding. +placement: append +order: 10 +--- +Ask questions until you are 100% sure you know exactly what to do. +Do not act on this until I confirm we have reached a shared understanding. \ No newline at end of file diff --git a/extensions/prompt-snippets/snippets/delegate-exploration.md b/extensions/prompt-snippets/snippets/delegate-exploration.md new file mode 100644 index 0000000..aebe672 --- /dev/null +++ b/extensions/prompt-snippets/snippets/delegate-exploration.md @@ -0,0 +1,7 @@ +--- +name: Delegate exploration +description: Keep context lean. Subagents explore, you verify critical parts +placement: append +order: 30 +--- +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. diff --git a/extensions/prompt-snippets/snippets/diagnose-report.md b/extensions/prompt-snippets/snippets/diagnose-report.md new file mode 100644 index 0000000..1005920 --- /dev/null +++ b/extensions/prompt-snippets/snippets/diagnose-report.md @@ -0,0 +1,7 @@ +--- +name: Diagnose, don't fix +description: Read-only, report findings and a proposed fix +placement: append +order: 40 +--- +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. diff --git a/extensions/prompt-snippets/snippets/orchestrator-mode.md b/extensions/prompt-snippets/snippets/orchestrator-mode.md new file mode 100644 index 0000000..0c41a61 --- /dev/null +++ b/extensions/prompt-snippets/snippets/orchestrator-mode.md @@ -0,0 +1,7 @@ +--- +name: Orchestrator mode +description: Pure high-level orchestrator session +placement: prepend +order: 30 +--- +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. diff --git a/extensions/prompt-snippets/snippets/session-kickoff.md b/extensions/prompt-snippets/snippets/session-kickoff.md new file mode 100644 index 0000000..ccf744d --- /dev/null +++ b/extensions/prompt-snippets/snippets/session-kickoff.md @@ -0,0 +1,7 @@ +--- +name: Session kickoff +description: Get oriented, report back before starting work +placement: prepend +order: 10 +--- +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. diff --git a/extensions/prompt-snippets/snippets/verify-not-assume.md b/extensions/prompt-snippets/snippets/verify-not-assume.md new file mode 100644 index 0000000..3eee4dd --- /dev/null +++ b/extensions/prompt-snippets/snippets/verify-not-assume.md @@ -0,0 +1,7 @@ +--- +name: Verify, don't assume +description: Do not assume — verify +placement: append +order: 20 +--- +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.