import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; type Idea = { id: number; text: string; tags: string[]; createdAt: string; updatedAt: string; notes: string[]; }; type DisplayMode = "simple" | "cozy" | "madmax"; type Store = { nextId: number; ideas: Idea[]; mode?: DisplayMode; hidden?: boolean; page?: number; filter?: string }; const DATA_DIR = join(homedir(), ".pi", "agent", "ideas"); const DATA_FILE = join(DATA_DIR, "ideas.json"); const DISPLAY_MODES: DisplayMode[] = ["simple", "cozy", "madmax"]; function now(): string { return new Date().toISOString(); } function loadStore(): Store { try { const parsed = JSON.parse(readFileSync(DATA_FILE, "utf8")) as Store; return { nextId: parsed.nextId || 1, ideas: Array.isArray(parsed.ideas) ? parsed.ideas : [], mode: parsed.mode === "simple" || parsed.mode === "cozy" || parsed.mode === "madmax" ? parsed.mode : "cozy", hidden: Boolean(parsed.hidden), page: Number.isFinite(parsed.page) ? Math.max(0, Math.floor(parsed.page as number)) : 0, filter: typeof parsed.filter === "string" ? parsed.filter : "" }; } catch { return { nextId: 1, ideas: [], mode: "cozy", hidden: false, page: 0, filter: "" }; } } function saveStore(store: Store): void { mkdirSync(DATA_DIR, { recursive: true }); writeFileSync(DATA_FILE, JSON.stringify(store, null, 2) + "\n", "utf8"); } function parseTags(text: string): { clean: string; tags: string[] } { const tags = [...text.matchAll(/#([\w-]+)/g)].map((m) => m[1].toLowerCase()); return { clean: text.replace(/\s+#([\w-]+)/g, "").trim(), tags: [...new Set(tags)] }; } function parseId(idText: string): number { return Number(idText.trim().replace(/^#/, "")); } function findIdea(store: Store, idText: string): Idea | undefined { const id = parseId(idText); return Number.isFinite(id) ? store.ideas.find((i) => i.id === id) : undefined; } function ideaTextWithTags(idea: Idea): string { const tags = idea.tags.length ? ` ${idea.tags.map((t) => `#${t}`).join(" ")}` : ""; return `${idea.text}${tags}`; } function ellipsize(text: string, width: number): string { const clean = text.replace(/\s+/g, " ").trim(); return clean.length <= width ? clean : `${clean.slice(0, Math.max(0, width - 1))}…`; } function ideaLine(idea: Idea): string { const notes = idea.notes.length ? ` · ${idea.notes.length} note${idea.notes.length === 1 ? "" : "s"}` : ""; return `#${idea.id} ${ideaTextWithTags(idea)}${notes}`; } const WIDGET_LINE_WIDTH = 93; const COZY_TEXT_WIDTH = WIDGET_LINE_WIDTH - 2; function simpleIdeaLines(idea: Idea): string[] { return [ellipsize(ideaLine(idea), WIDGET_LINE_WIDTH)]; } function cozyIdeaLines(idea: Idea): string[] { const tags = idea.tags.length ? idea.tags.map((t) => `#${t}`).join(" ") : "no tags"; const note = idea.notes[0] ? `${idea.notes[0]}${idea.notes.length > 1 ? ` (+${idea.notes.length - 1})` : ""}` : "no notes yet"; const header = `╭─ ✦ #${idea.id} `; return [ `${header}${ellipsize(idea.text, WIDGET_LINE_WIDTH - header.length)}`, `│ 🏷 ${ellipsize(tags, WIDGET_LINE_WIDTH - 5)}`, `│ ✎ ${ellipsize(note, WIDGET_LINE_WIDTH - 5)}`, `╰${"─".repeat(34)}`, ]; } function madMaxIdeaLines(idea: Idea): string[] { const tags = idea.tags.length ? idea.tags.map((t) => `#${t}`).join(" ") : "no faction tags"; const note = idea.notes[0] ? `${idea.notes[0]}${idea.notes.length > 1 ? ` (+${idea.notes.length - 1} more scraps)` : ""}` : "no war notes yet"; const header = `☠ #${idea.id} WITNESS: `; return [ `${header}${ellipsize(idea.text.toUpperCase(), WIDGET_LINE_WIDTH - header.length)}`, ` ⛽ ${ellipsize(tags, WIDGET_LINE_WIDTH - 5)}`, ` 🔧 ${ellipsize(note, WIDGET_LINE_WIDTH - 5)}`, ` ride: /idea:vibe ${idea.id} scrap: /idea:dump ${idea.id}`, ]; } function ideaLines(idea: Idea, mode: DisplayMode): string[] { if (mode === "simple") return simpleIdeaLines(idea); if (mode === "madmax") return madMaxIdeaLines(idea); return cozyIdeaLines(idea); } function listIdeas(store: Store, filter?: string): Idea[] { const q = filter?.trim().toLowerCase(); const ideas = store.ideas; if (!q) return ideas.sort((a, b) => b.id - a.id); if (q.startsWith("#")) return ideas.filter((i) => i.tags.includes(q.slice(1))).sort((a, b) => b.id - a.id); return ideas.filter((i) => `${i.text} ${i.tags.join(" ")} ${i.notes.join(" ")}`.toLowerCase().includes(q)).sort((a, b) => b.id - a.id); } function help(): string { return `Ideas:\n/idea [#tag] save one\n/ideas [filter|#tag] show ideas\n/ideas next|prev page the widget\n/ideas:hide hide the widget\n/idea:edit fix the text/tags\n/idea:note add context\n/idea:dump dump ideas\n/idea:mode change list display\n/idea:work draft one build prompt`; } function pageSize(mode: DisplayMode): number { return mode === "simple" ? 8 : 2; } function pageCount(total: number, size: number): number { return Math.max(1, Math.ceil(total / size)); } function showList(ctx: any, store: Store, notify = true): void { const filter = store.filter ?? ""; const ideas = listIdeas(store, filter); const mode = store.mode ?? "cozy"; const size = pageSize(mode); const pages = pageCount(ideas.length, size); const page = Math.min(Math.max(0, store.page ?? 0), pages - 1); store.page = page; const shown = ideas.slice(page * size, page * size + size); const filterText = filter ? ` · ${filter}` : ""; const title = mode === "cozy" ? `✦ Ideas ${page + 1}/${pages}${filterText}` : mode === "madmax" ? `🔥 WASTELAND IDEAS ${page + 1}/${pages}${filterText}` : `Ideas ${page + 1}/${pages}${filterText} · ${mode}`; const footer = mode === "madmax" ? (ideas.length > size ? `⛽ /ideas prev · /ideas next · ${ideas.length} scraps` : `${ideas.length} scraps · /ideas:hide`) : (ideas.length > size ? `◂ /ideas prev · /ideas next ▸ · ${ideas.length} total` : `${ideas.length} total · /ideas:hide`); const lines = ideas.length ? [title, ...shown.flatMap((idea) => ideaLines(idea, mode)), footer] : [title, "No matching ideas.", "/ideas:hide"]; ctx.ui.setWidget?.("ideas", lines, { placement: "aboveEditor" }); saveStore(store); if (notify) ctx.ui.notify(ideas.length ? `Showing page ${page + 1}/${pages}` : "No matching ideas", ideas.length ? "info" : "warning"); } function showListAndUnhide(ctx: any, store: Store, notify = true): void { if (store.hidden) store.hidden = false; showList(ctx, store, notify); } function refreshList(ctx: any, force = false): void { const store = loadStore(); if (force) { store.hidden = false; store.page = 0; } if (!store.hidden) showList(ctx, store, false); else saveStore(store); } function workPrompt(idea: Idea): string { return `Turn this saved idea into a small, shippable plan for this repo. Keep it practical. Include scope, first version, files to change, edge cases, and a short checklist.\n\n${ideaLine(idea)}\n${idea.notes.length ? `\nNotes:\n${idea.notes.map((n) => `- ${n}`).join("\n")}` : ""}`; } export default function (pi: ExtensionAPI) { pi.registerCommand("idea", { description: "Save an idea. Usage: /idea [#tag]", handler: async (args, ctx) => { const text = args.trim(); if (!text) return ctx.ui.notify(help(), "info"); const store = loadStore(); const parsed = parseTags(text); const idea: Idea = { id: store.nextId++, text: parsed.clean || text, tags: parsed.tags, createdAt: now(), updatedAt: now(), notes: [] }; store.ideas.push(idea); saveStore(store); refreshList(ctx, true); ctx.ui.notify(`Saved idea #${idea.id}`, "success"); }, }); pi.registerCommand("ideas", { description: "Show ideas. Usage: /ideas [filter|#tag|next|prev|page]", handler: async (args, ctx) => { const input = args.trim(); const command = input.toLowerCase(); const store = loadStore(); if (["next", "n", "+"].includes(command)) { store.page = (store.page ?? 0) + 1; } else if (["prev", "previous", "p", "-"].includes(command)) { store.page = Math.max(0, (store.page ?? 0) - 1); } else if (/^\d+$/.test(command)) { store.page = Math.max(0, Number(command) - 1); } else { store.filter = input; store.page = 0; } showListAndUnhide(ctx, store); }, }); pi.registerCommand("ideas:next", { description: "Show next page of ideas", handler: async (_args, ctx) => { const store = loadStore(); store.page = (store.page ?? 0) + 1; showListAndUnhide(ctx, store); }, }); pi.registerCommand("ideas:prev", { description: "Show previous page of ideas", handler: async (_args, ctx) => { const store = loadStore(); store.page = Math.max(0, (store.page ?? 0) - 1); showListAndUnhide(ctx, store); }, }); pi.registerCommand("ideas:hide", { description: "Hide the ideas widget until the next ideas command", handler: async (_args, ctx) => { const store = loadStore(); store.hidden = true; saveStore(store); ctx.ui.setWidget?.("ideas", undefined); ctx.ui.notify("Ideas hidden", "info"); }, }); pi.registerCommand("idea:edit", { description: "Edit an idea. Usage: /idea:edit [extra text]", handler: async (args, ctx) => { const [idText, ...rest] = args.trim().split(/\s+/); const text = rest.join(" ").trim(); const store = loadStore(); const idea = findIdea(store, idText || ""); if (!idea) return ctx.ui.notify("Usage: /idea:edit [extra text]", "error"); const current = ideaTextWithTags(idea); const prefill = text ? `${current} ${text}` : current; const edited = ctx.ui.editor ? await ctx.ui.editor(`Edit idea #${idea.id}`, prefill) : prefill; const nextText = edited?.trim(); if (!nextText) return ctx.ui.notify("Edit cancelled", "info"); const parsed = parseTags(nextText); idea.text = parsed.clean || nextText; idea.tags = parsed.tags; idea.updatedAt = now(); saveStore(store); refreshList(ctx, true); ctx.ui.notify(`Updated idea #${idea.id}`, "success"); }, }); pi.registerCommand("idea:note", { description: "Add a note. Usage: /idea:note ", handler: async (args, ctx) => { const [idText, ...rest] = args.trim().split(/\s+/); const note = rest.join(" ").trim(); const store = loadStore(); const idea = findIdea(store, idText || ""); if (!idea || !note) return ctx.ui.notify("Usage: /idea:note ", "error"); idea.notes.push(note); idea.updatedAt = now(); saveStore(store); refreshList(ctx, true); ctx.ui.notify(`Added note to #${idea.id}`, "success"); }, }); pi.registerCommand("idea:dump", { description: "Dump an idea or all ideas. Usage: /idea:dump ", handler: async (args, ctx) => { const target = args.trim().toLowerCase(); const store = loadStore(); if (target === "all") { const confirmed = ctx.ui.confirm ? await ctx.ui.confirm("Delete all ideas?", "This removes every saved idea. Your questionable genius will be gone.") : false; if (!confirmed) return ctx.ui.notify("Delete cancelled", "info"); store.ideas = []; store.nextId = 1; store.page = 0; saveStore(store); refreshList(ctx, true); return ctx.ui.notify("Deleted all ideas", "success"); } const id = parseId(target); const idea = Number.isFinite(id) ? store.ideas.find((i) => i.id === id) : undefined; if (!idea) return ctx.ui.notify("Usage: /idea:dump ", "error"); const confirmed = ctx.ui.confirm ? await ctx.ui.confirm(`Delete idea #${idea.id}?`, idea.text) : true; if (!confirmed) return ctx.ui.notify("Delete cancelled", "info"); store.ideas = store.ideas.filter((i) => i.id !== idea.id); saveStore(store); refreshList(ctx, true); ctx.ui.notify(`Deleted idea #${idea.id}`, "success"); }, }); pi.registerCommand("idea:mode", { description: "Set ideas display mode. Usage: /idea:mode ", getArgumentCompletions: (prefix: string) => DISPLAY_MODES.map((m) => ({ value: m, label: m })).filter((i) => i.value.startsWith(prefix.split(/\s+/).at(-1) || "")), handler: async (args, ctx) => { const mode = args.trim().toLowerCase() as DisplayMode; if (!DISPLAY_MODES.includes(mode)) return ctx.ui.notify("Usage: /idea:mode simple|cozy|madmax", "error"); const store = loadStore(); store.mode = mode; saveStore(store); if (!store.hidden) showList(ctx, store, false); ctx.ui.notify(`Ideas display: ${mode}`, "success"); }, }); pi.registerCommand("idea:work", { description: "Draft one build prompt. Usage: /idea:work ", handler: async (args, ctx) => { const store = loadStore(); const idea = findIdea(store, args); if (!idea) return ctx.ui.notify("Usage: /idea:work ", "error"); ctx.ui.setEditorText(workPrompt(idea)); }, }); pi.registerCommand("idea:vibe", { description: "Alias for /idea:work, but lets vibe 🎧. Usage: /idea:vibe ", handler: async (args, ctx) => { const store = loadStore(); const idea = findIdea(store, args); if (!idea) return ctx.ui.notify("Usage: /idea:vibe ", "error"); ctx.ui.setEditorText(workPrompt(idea)); }, }); }