/** * EasyMode — All-in-one beginner-friendly Pi extension * * Built-in features: * šŸŽÆ Goal Keeper — set a focus so the AI stays on track * šŸ›”ļø Safety Guard — catches dangerous commands before they run * šŸ“Š Smart Footer — model, context bar, cost, git branch, tool tally * šŸ¤– Agent Presets — switch AI personality with /agent * 🟢 /menu — visual command picker (no memorization needed) * šŸ›‘ /stop — abort the current AI action * šŸ’” /explain — re-explain the last action in plain English * šŸ”– /bookmark — save + list important moments * ā†©ļø /undo — how to revert the last file change * šŸ“‹ /status — full session dashboard * šŸ“– /help — quick reference * šŸ”„ /quickstart — re-run the welcome wizard * * Usage: pi -e extensions/easymode.ts * * Or with theme cycling: * pi -e extensions/easymode.ts -e extensions/theme-cycler.ts */ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { isToolCallEventType } from "@mariozechner/pi-coding-agent"; import type { AssistantMessage } from "@mariozechner/pi-ai"; import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { basename } from "node:path"; import { applyExtensionDefaults } from "./themeMap.ts"; // ═══════════════════════════════════════════════════════════════════════════ // Agent Presets // ═══════════════════════════════════════════════════════════════════════════ interface AgentPreset { name: string; emoji: string; description: string; systemPrompt: string; } const AGENT_PRESETS: AgentPreset[] = [ { name: "Default", emoji: "šŸ¤–", description: "Standard coding assistant — no restrictions", systemPrompt: "", }, { name: "Explain Like I'm 5", emoji: "šŸ‘¶", description: "Explains everything in the simplest terms possible", systemPrompt: `You are a patient teacher. The user is a beginner. - Explain EVERY concept in simple terms before using it - Use analogies and real-world comparisons - Show the "before" and "after" of every change - Never assume the user knows technical jargon — define it first - After each code change, add a brief "What just happened?" summary - If a command could break something, warn the user FIRST`, }, { name: "Guided Builder", emoji: "šŸ—ļø", description: "Step-by-step building with confirmations at each stage", systemPrompt: `You are a guided builder. Help the user build things step-by-step. - Break every task into numbered steps and show the plan first - Ask for confirmation before each major step - After each change, briefly explain what you did and why - If there are multiple approaches, list 2-3 options with pros/cons - Always suggest the safest option as the default - Show file paths and explain the project structure as you go`, }, { name: "Code Reviewer", emoji: "šŸ”", description: "Reviews code and suggests improvements without changing anything", systemPrompt: `You are a code reviewer. Review, don't modify. - DO NOT write or edit any files unless explicitly asked - Point out bugs, security issues, and improvement opportunities - Rate code quality on a 1-5 scale with specific reasons - Suggest improvements as clear before/after examples - Focus on: readability, security, performance, maintainability - Be encouraging — highlight what's done well too`, }, { name: "Debug Detective", emoji: "šŸ•µļø", description: "Focused on finding and fixing bugs", systemPrompt: `You are a debug detective. Help find and fix bugs systematically. - Always ask to see error messages and logs first - Explain your debugging thought process step by step - List possible causes ranked by likelihood - Test hypotheses one at a time - After fixing, explain what caused the bug and how to prevent it - Suggest adding error handling or tests to prevent recurrence`, }, { name: "Safe Mode", emoji: "šŸ”’", description: "Extra cautious — confirms every file modification", systemPrompt: `You are in Safe Mode. Maximum caution. - ALWAYS show the exact changes you plan to make BEFORE making them - Ask for explicit "yes" confirmation before ANY file write, edit, or bash command - Never run destructive commands (rm, drop, truncate, etc.) without double-confirming - Create backups before modifying existing files when possible - After each change, verify the change was correct by reading the file back - If something goes wrong, immediately suggest how to undo it`, }, ]; // ═══════════════════════════════════════════════════════════════════════════ // Dangerous Command Patterns // ═══════════════════════════════════════════════════════════════════════════ const DANGEROUS_PATTERNS: { pattern: RegExp; reason: string }[] = [ { pattern: /rm\s+(-[rRf]+\s+|.*\s+-[rRf]+)/, reason: "Recursive/forced delete detected" }, { pattern: /rm\s+-rf\s+[\/~]/, reason: "Deleting from root or home directory!" }, { pattern: /DROP\s+(TABLE|DATABASE|SCHEMA)/i, reason: "SQL DROP command detected" }, { pattern: /TRUNCATE\s+TABLE/i, reason: "SQL TRUNCATE command detected" }, { pattern: /DELETE\s+FROM\s+\w+\s*(;|$)/i, reason: "DELETE without WHERE clause" }, { pattern: />\s*\/dev\/sd[a-z]/, reason: "Writing directly to disk device" }, { pattern: /mkfs\./, reason: "Filesystem format command detected" }, { pattern: /dd\s+if=/, reason: "dd command — raw disk copy" }, { pattern: /chmod\s+-R\s+777/, reason: "Recursive world-writable permissions" }, { pattern: /:(){ :\|:& };:/, reason: "Fork bomb detected!" }, { pattern: /curl.*\|\s*(ba)?sh/, reason: "Piping remote script to shell" }, { pattern: /wget.*\|\s*(ba)?sh/, reason: "Piping remote script to shell" }, { pattern: /git\s+push\s+.*--force/, reason: "Force push — may overwrite remote history" }, { pattern: /git\s+reset\s+--hard/, reason: "Hard reset — may lose uncommitted changes" }, { pattern: /npm\s+publish/, reason: "Publishing to npm registry" }, { pattern: /docker\s+system\s+prune/, reason: "Docker system prune — removes all unused data" }, ]; // ═══════════════════════════════════════════════════════════════════════════ // Extension // ═══════════════════════════════════════════════════════════════════════════ export default function (pi: ExtensionAPI) { let purpose: string | undefined; let activePreset: AgentPreset = AGENT_PRESETS[1]; // Default to "Explain Like I'm 5" for beginners let activeCtx: ExtensionContext | undefined; const toolCounts: Record = {}; const bookmarks: { timestamp: string; note: string }[] = []; let lastFileChange: { tool: string; path: string } | null = null; let toolRunsSinceLastTip = 0; // ── Tool tracking + contextual tips ─────────────────────────────── pi.on("tool_execution_end", async (event) => { toolCounts[event.toolName] = (toolCounts[event.toolName] || 0) + 1; // Show a helpful hint every 5 tool runs toolRunsSinceLastTip++; if (activeCtx && toolRunsSinceLastTip >= 5) { toolRunsSinceLastTip = 0; const tips = [ "šŸ’” Confused? Type /explain and the AI will break down what just happened.", "šŸ’” Want to save your progress? Type /bookmark to mark this moment.", "šŸ’” Type /menu to see everything you can do.", "šŸ’” Things going wrong? Type /stop to pause the AI immediately.", "šŸ’” Type /undo to see how to reverse the last file change.", "šŸ’” Type /status to see a summary of your whole session.", ]; const tip = tips[Math.floor(Math.random() * tips.length)]; activeCtx.ui.notify(tip, "info"); } }); // ── Track file changes for /undo ────────────────────────────────── pi.on("tool_call", async (event, _ctx) => { if (isToolCallEventType("write", event) || isToolCallEventType("edit", event)) { lastFileChange = { tool: event.toolName, path: event.input.path }; } return { block: false }; }); // ── Welcome Wizard ──────────────────────────────────────────────── async function runWelcomeWizard(ctx: ExtensionContext) { // Step 1: Welcome ctx.ui.notify( "šŸ‘‹ Welcome to EasyMode!\n\n" + "This is your AI coding assistant. You type what you want\n" + "in plain English, and it writes the code for you.\n\n" + "Let's get you set up in 30 seconds...", "info" ); // Step 2: Pick a personality const personalityOptions = AGENT_PRESETS.map((p) => `${p.emoji} ${p.name} — ${p.description}`); const personalityChoice = await ctx.ui.select( "Step 1 of 2: How should the AI talk to you?", personalityOptions ); if (personalityChoice !== undefined) { const idx = personalityOptions.indexOf(personalityChoice); activePreset = AGENT_PRESETS[idx]; ctx.ui.setStatus("easymode", `${activePreset.emoji} ${activePreset.name}`); ctx.ui.notify(`Great choice! ${activePreset.emoji} ${activePreset.name} activated.`, "success"); } else { ctx.ui.notify(`Using default: ${activePreset.emoji} ${activePreset.name}`, "info"); } // Step 3: Set a goal const answer = await ctx.ui.input( "Step 2 of 2: What do you want to do today?", "Examples: 'Make a website', 'Fix the bug on line 42', 'Explain this code to me'" ); if (answer && answer.trim()) { purpose = answer.trim(); ctx.ui.notify(`šŸŽÆ Goal set: ${purpose}`, "success"); setPurposeWidget(ctx); } else { ctx.ui.notify("No problem! You can set a goal anytime by typing /goal", "info"); } // Step 4: Quick orientation ctx.ui.notify( "āœ… You're all set! Here's what you need to know:\n\n" + " šŸ“ Just type what you want in plain English\n" + " 🟢 /menu — see all available commands\n" + " šŸ›‘ /stop — stop the AI if it's doing something wrong\n" + " ā“ /explain — ask the AI to explain what it just did\n\n" + "āš ļø The AI will ask for your OK before doing anything dangerous.\n\n" + "Go ahead — type your first message! šŸš€", "success" ); } // ── Session start ───────────────────────────────────────────────── pi.on("session_start", async (_event, ctx) => { applyExtensionDefaults(import.meta.url, ctx); activeCtx = ctx; // Fire-and-forget — don't block TUI startup void runWelcomeWizard(ctx); // Footer ctx.ui.setFooter((tui, theme, footerData) => { const unsub = footerData.onBranchChange(() => tui.requestRender()); return { dispose: unsub, invalidate() {}, render(width: number): string[] { // Accumulate tokens + cost let tokIn = 0, tokOut = 0, cost = 0; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "message" && entry.message.role === "assistant") { const m = entry.message as AssistantMessage; tokIn += m.usage.input; tokOut += m.usage.output; cost += m.usage.cost.total; } } const fmt = (n: number) => n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`; const model = ctx.model?.id || "no-model"; const dir = basename(ctx.cwd); const branch = footerData.getGitBranch(); // Context bar const usage = ctx.getContextUsage(); const pct = usage?.percent ?? 0; const filled = Math.round(pct / 10); const bar = "ā–ˆ".repeat(filled) + "ā–‘".repeat(10 - filled); // Line 1: model + context + agent + cost const l1Left = theme.fg("dim", ` ${model} `) + (pct < 70 ? theme.fg("success", bar) : pct < 90 ? theme.fg("warning", bar) : theme.fg("error", bar)) + theme.fg("dim", ` ${Math.round(pct)}% `) + theme.fg("accent", `${activePreset.emoji} ${activePreset.name}`); const l1Right = theme.fg("success", fmt(tokIn)) + theme.fg("dim", "↓ ") + theme.fg("accent", fmt(tokOut)) + theme.fg("dim", "↑ ") + theme.fg("warning", `$${cost.toFixed(4)}`) + theme.fg("dim", " "); const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right))); const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, ""); // Line 2: cwd + branch + tools const l2Left = theme.fg("dim", ` ${dir}`) + (branch ? theme.fg("dim", " ") + theme.fg("warning", "(") + theme.fg("success", branch) + theme.fg("warning", ")") : ""); const entries = Object.entries(toolCounts); const l2Right = entries.length === 0 ? theme.fg("dim", "ready ") : entries .map(([name, count]) => theme.fg("accent", name) + theme.fg("dim", ":") + theme.fg("success", `${count}`)) .join(theme.fg("dim", " ")) + " "; const pad2 = " ".repeat(Math.max(1, width - visibleWidth(l2Left) - visibleWidth(l2Right))); const line2 = truncateToWidth(l2Left + pad2 + l2Right, width, ""); return [line1, line2]; }, }; }); ctx.ui.setStatus("easymode", `${activePreset.emoji} ${activePreset.name}`); }); pi.on("session_switch", async (_event, ctx) => { activeCtx = ctx; }); // ── Safety guard ────────────────────────────────────────────────── pi.on("tool_call", async (event, ctx) => { if (isToolCallEventType("bash", event)) { const cmd = event.input.command; for (const { pattern, reason } of DANGEROUS_PATTERNS) { if (pattern.test(cmd)) { const confirmed = await ctx.ui.confirm( "āš ļø Safety Warning", `${reason}\n\nCommand: ${cmd}\n\nAre you sure you want to run this?`, { timeout: 30000 } ); if (!confirmed) { ctx.ui.notify(`šŸ›”ļø Blocked: ${reason}`, "warning"); ctx.abort(); return { block: true, reason: `šŸ›‘ BLOCKED by Safety Guard: ${reason}\n\nThe user chose not to run this command. Ask them what they'd like to do instead.`, }; } break; } } } return { block: false }; }); // ── System prompt injection ─────────────────────────────────────── pi.on("before_agent_start", async (event) => { let extra = ""; if (activePreset.systemPrompt) { extra += `\n\n\n${activePreset.systemPrompt}\n`; } if (purpose) { extra += `\n\n\nThe user's goal for this session: ${purpose}\nKeep this goal in mind. If the conversation drifts, gently guide back.\n`; } if (!extra) return; return { systemPrompt: event.systemPrompt + extra }; }); // ── Helper ──────────────────────────────────────────────────────── function setPurposeWidget(ctx: ExtensionContext) { ctx.ui.setWidget("purpose", () => ({ render(width: number): string[] { const label = " āœ… Working on: "; const content = purpose!; const line = truncateToWidth(label + content, width - 2, "…"); return [ " ".repeat(width), line + " ".repeat(Math.max(0, width - visibleWidth(line))), " ".repeat(width), ]; }, invalidate() {}, })); } // ═══════════════════════════════════════════════════════════════════ // Commands // ═══════════════════════════════════════════════════════════════════ pi.registerCommand("help", { description: "Show all EasyMode commands", handler: async (_args, ctx) => { ctx.ui.notify( "šŸ“– EasyMode Commands:\n\n" + " /menu — 🟢 START HERE — pick a command from a list\n" + " /help — You're looking at it!\n" + " /agent — Switch AI personality (beginner, builder, reviewer...)\n" + " /goal — Set or change your session goal\n" + " /stop — Immediately stop the current AI action\n" + " /explain — Ask the AI to explain what it just did\n" + " /bookmark — Save the current moment with a note\n" + " /bookmarks — See all saved bookmarks\n" + " /undo — Show how to undo the last file change\n" + " /status — Show session overview (goal, agent, stats)\n" + " /quickstart — Re-run the setup wizard\n" + "\nšŸ’” Tips:\n" + " • Just type what you want in plain English — no special syntax needed\n" + " • The bottom bar shows your context usage and cost\n" + " • āš ļø Dangerous commands always ask for your OK first", "info" ); }, }); pi.registerCommand("menu", { description: "Pick a command from a visual menu", handler: async (_args, ctx) => { const menuItems = [ "šŸŽÆ Set or change my goal", "šŸ¤– Switch AI personality", "šŸ’” Explain what just happened", "šŸ”– Save a bookmark", "šŸ“‹ See all my bookmarks", "ā†©ļø Undo the last file change", "šŸ“Š Show session status", "šŸ›‘ Stop the AI", "šŸ”„ Re-run setup wizard", "šŸ“– Show all commands (help)", ]; const choice = await ctx.ui.select("What would you like to do?", menuItems); if (choice === undefined) return; const idx = menuItems.indexOf(choice); switch (idx) { case 0: { // Goal const answer = await ctx.ui.input("šŸŽÆ What's your goal?", purpose || "e.g. Fix the login bug..."); if (answer && answer.trim()) { purpose = answer.trim(); ctx.ui.notify(`šŸŽÆ Goal updated: ${purpose}`, "success"); setPurposeWidget(ctx); } return; } case 1: { // Agent const options = AGENT_PRESETS.map((p) => `${p.emoji} ${p.name} — ${p.description}`); const agentChoice = await ctx.ui.select("Select AI Personality", options); if (agentChoice !== undefined) { const agentIdx = options.indexOf(agentChoice); activePreset = AGENT_PRESETS[agentIdx]; ctx.ui.setStatus("easymode", `${activePreset.emoji} ${activePreset.name}`); ctx.ui.notify(`Switched to: ${activePreset.emoji} ${activePreset.name}\n${activePreset.description}`, "success"); } return; } case 2: // Explain ctx.sendMessage("Please explain what you just did in simple terms that a beginner would understand. Use bullet points and avoid jargon. If you wrote code, explain what each part does."); return; case 3: { // Bookmark const note = await ctx.ui.input("šŸ”– Bookmark note", "e.g. Got login working, Before refactor..."); if (note && note.trim()) { const ts = new Date().toLocaleTimeString(); bookmarks.push({ timestamp: ts, note: note.trim() }); pi.appendEntry("easymode-bookmarks", { timestamp: ts, note: note.trim() }); ctx.ui.notify(`šŸ”– Saved: ${note.trim()} (${ts})`, "success"); } return; } case 4: { // Bookmarks if (bookmarks.length === 0) return ctx.ui.notify("No bookmarks yet. Use /bookmark to save one.", "info"); const list = bookmarks.map((b, i) => ` ${i + 1}. [${b.timestamp}] ${b.note}`).join("\n"); return ctx.ui.notify(`šŸ”– Bookmarks:\n\n${list}`, "info"); } case 5: { // Undo if (!lastFileChange) { return ctx.ui.notify("No file changes tracked yet.\n\nšŸ’” You can also tell the AI: \"Please undo the last change\"", "info"); } return ctx.ui.notify( `ā†©ļø Last change: ${lastFileChange.tool} → ${lastFileChange.path}\n\n` + 'Tell the AI: "Please revert the last change"\n' + `Or run: git checkout -- ${lastFileChange.path}`, "info" ); } case 6: { // Status const usage = ctx.getContextUsage(); const pct = usage?.percent ?? 0; let tokIn = 0, tokOut = 0, cost = 0; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "message" && entry.message.role === "assistant") { const m = entry.message as AssistantMessage; tokIn += m.usage.input; tokOut += m.usage.output; cost += m.usage.cost.total; } } return ctx.ui.notify( `šŸ“‹ Session Status\n\n` + ` šŸŽÆ Goal: ${purpose || "(not set)"}\n` + ` šŸ¤– Agent: ${activePreset.emoji} ${activePreset.name}\n` + ` šŸ“Š Context: ${Math.round(pct)}% used\n` + ` šŸ’° Cost: $${cost.toFixed(4)}`, "info" ); } case 7: // Stop ctx.abort(); return ctx.ui.notify("šŸ›‘ Stopped.", "warning"); case 8: // Quickstart await runWelcomeWizard(ctx); return; case 9: // Help return ctx.ui.notify( "šŸ“– All commands start with / (slash):\n\n" + " /menu /goal /agent /explain /bookmark\n" + " /bookmarks /undo /status /stop /quickstart /help\n\n" + "Or just type what you want in plain English!", "info" ); } }, }); pi.registerCommand("quickstart", { description: "Re-run the welcome wizard", handler: async (_args, ctx) => { await runWelcomeWizard(ctx); }, }); pi.registerCommand("agent", { description: "Switch AI personality preset", handler: async (_args, ctx) => { const options = AGENT_PRESETS.map((p) => `${p.emoji} ${p.name} — ${p.description}`); const choice = await ctx.ui.select("Select AI Personality", options); if (choice === undefined) return; const idx = options.indexOf(choice); activePreset = AGENT_PRESETS[idx]; ctx.ui.setStatus("easymode", `${activePreset.emoji} ${activePreset.name}`); ctx.ui.notify(`Switched to: ${activePreset.emoji} ${activePreset.name}\n${activePreset.description}`, "success"); }, }); pi.registerCommand("goal", { description: "Set or change your session goal", handler: async (_args, ctx) => { const answer = await ctx.ui.input("šŸŽÆ What's your goal?", purpose || "e.g. Fix the login bug..."); if (answer && answer.trim()) { purpose = answer.trim(); ctx.ui.notify(`šŸŽÆ Goal updated: ${purpose}`, "success"); setPurposeWidget(ctx); } }, }); pi.registerCommand("stop", { description: "Immediately stop the current AI action", handler: async (args, ctx) => { const reason = (args || "").trim(); ctx.abort(); ctx.ui.notify(reason ? `šŸ›‘ Stopped: ${reason}` : "šŸ›‘ Stopped.", "warning"); }, }); pi.registerCommand("explain", { description: "Ask the AI to explain its last action in simple terms", handler: async (_args, ctx) => { ctx.ui.notify("šŸ’” Asking AI to explain...", "info"); ctx.sendMessage( "Please explain what you just did in simple terms that a beginner would understand. " + "Use bullet points and avoid jargon. If you wrote code, explain what each part does." ); }, }); pi.registerCommand("bookmark", { description: "Save a bookmark with a note", handler: async (_args, ctx) => { const note = await ctx.ui.input("šŸ”– Bookmark note", "e.g. Got login working, Before refactor..."); if (note && note.trim()) { const ts = new Date().toLocaleTimeString(); bookmarks.push({ timestamp: ts, note: note.trim() }); pi.appendEntry("easymode-bookmarks", { timestamp: ts, note: note.trim() }); ctx.ui.notify(`šŸ”– Saved: ${note.trim()} (${ts})`, "success"); } }, }); pi.registerCommand("bookmarks", { description: "Show all saved bookmarks", handler: async (_args, ctx) => { if (bookmarks.length === 0) { ctx.ui.notify("No bookmarks yet. Use /bookmark to save one.", "info"); return; } const list = bookmarks.map((b, i) => ` ${i + 1}. [${b.timestamp}] ${b.note}`).join("\n"); ctx.ui.notify(`šŸ”– Bookmarks:\n\n${list}`, "info"); }, }); pi.registerCommand("undo", { description: "Show how to undo the last file change", handler: async (_args, ctx) => { if (!lastFileChange) { ctx.ui.notify( "No file changes tracked yet.\n\n" + "šŸ’” General undo:\n" + " git checkout -- revert to last commit\n" + " git diff see what changed\n" + " git stash save & revert everything", "info" ); return; } ctx.ui.notify( `ā†©ļø Last change: ${lastFileChange.tool} → ${lastFileChange.path}\n\n` + "To undo:\n" + ` git checkout -- ${lastFileChange.path}\n\n` + "See diff:\n" + ` git diff ${lastFileChange.path}\n\n` + 'Or tell the AI: "Please revert the last change"', "info" ); }, }); pi.registerCommand("status", { description: "Show session overview", handler: async (_args, ctx) => { const usage = ctx.getContextUsage(); const pct = usage?.percent ?? 0; let tokIn = 0, tokOut = 0, cost = 0; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "message" && entry.message.role === "assistant") { const m = entry.message as AssistantMessage; tokIn += m.usage.input; tokOut += m.usage.output; cost += m.usage.cost.total; } } const entries = Object.entries(toolCounts); const toolSummary = entries.length === 0 ? " None yet" : entries.map(([n, c]) => ` ${n}: ${c}`).join("\n"); ctx.ui.notify( `šŸ“‹ Session Status\n\n` + ` šŸŽÆ Goal: ${purpose || "(not set — use /goal)"}\n` + ` šŸ¤– Agent: ${activePreset.emoji} ${activePreset.name}\n` + ` šŸ“Š Context: ${Math.round(pct)}% used\n` + ` šŸ’° Cost: $${cost.toFixed(4)}\n` + ` šŸ“Ø Tokens: ${tokIn} in / ${tokOut} out\n` + ` šŸ”– Bookmarks: ${bookmarks.length}\n` + `\nšŸ”§ Tools Used:\n${toolSummary}`, "info" ); }, }); }