index.ts
1 import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; 2 import { extractSnippets, extractText } from "./snippets"; 3 import type { Snippet } from "./snippets"; 4 import { pickAction, pickSnippet } from "./ui"; 5 import type { PickResult } from "./ui"; 6 import { copyToClipboard, insertIntoEditor, runSnippet } from "./actions"; 7 8 type ParsedArgs = { 9 scope: "last" | "all"; 10 action?: "copy" | "insert" | "run"; 11 index?: number; 12 includeInline: boolean; 13 limit: number; 14 }; 15 16 function parseArgs(args?: string): ParsedArgs { 17 const tokens = args?.trim().split(/\s+/).filter(Boolean) ?? []; 18 const parsed: ParsedArgs = { scope: "all", includeInline: true, limit: 200 }; 19 20 for (const token of tokens) { 21 if (token === "all" || token === "last") { 22 parsed.scope = token; 23 continue; 24 } 25 if (token === "inline") { 26 parsed.includeInline = true; 27 continue; 28 } 29 if (token === "blocks") { 30 parsed.includeInline = false; 31 continue; 32 } 33 if (token === "copy" || token === "insert" || token === "run") { 34 parsed.action = token; 35 continue; 36 } 37 if (token.startsWith("limit=")) { 38 const value = Number.parseInt(token.slice("limit=".length), 10); 39 if (!Number.isNaN(value) && value > 0) parsed.limit = value; 40 continue; 41 } 42 if (/^\d+$/.test(token)) { 43 parsed.index = Math.max(0, Number.parseInt(token, 10) - 1); 44 } 45 } 46 47 return parsed; 48 } 49 50 function collectSnippets( 51 ctx: ExtensionCommandContext, 52 scope: "last" | "all", 53 includeInline: boolean, 54 limit: number, 55 ): Snippet[] { 56 const branchEntries = ctx.sessionManager.getBranch(); 57 const candidateEntries = branchEntries.filter((entry) => { 58 if (entry.type !== "message") return false; 59 const role = entry.message?.role; 60 return role === "assistant" || role === "toolResult"; 61 }); 62 63 if (candidateEntries.length === 0) return []; 64 65 const sorted = candidateEntries.slice().sort((a, b) => { 66 const aTime = Date.parse(a.timestamp); 67 const bTime = Date.parse(b.timestamp); 68 return bTime - aTime; 69 }); 70 71 const entriesToScan = scope === "all" ? sorted : [sorted[0]!]; 72 73 let snippets: Snippet[] = []; 74 let nextId = 0; 75 for (const entry of entriesToScan) { 76 if (snippets.length >= limit) break; 77 const label = new Date(entry.timestamp).toLocaleTimeString(); 78 79 // Extract bash tool calls directly (these often render as `$ ...` in the UI but are stored structurally) 80 if (entry.type === "message" && entry.message?.role === "assistant" && Array.isArray(entry.message.content)) { 81 for (const block of entry.message.content as any[]) { 82 if (snippets.length >= limit) break; 83 if (!block || typeof block !== "object") continue; 84 if (block.type !== "toolCall") continue; 85 if (block.name !== "bash") continue; 86 const cmd = typeof block.arguments?.command === "string" ? block.arguments.command.trim() : ""; 87 if (!cmd) continue; 88 89 snippets.push({ 90 id: nextId, 91 type: "block", 92 language: "bash", 93 content: cmd, 94 messageId: entry.id, 95 sourceLabel: label, 96 }); 97 nextId += 1; 98 } 99 } 100 101 const text = extractText(entry.message.content); 102 if (!text) continue; 103 const extracted = extractSnippets(text, entry.id, label, nextId, includeInline, limit - snippets.length); 104 snippets = snippets.concat(extracted); 105 nextId = snippets.length; 106 } 107 108 return snippets; 109 } 110 111 export default function codeActionsExtension(pi: ExtensionAPI) { 112 pi.registerCommand("code", { 113 description: "Pick code from assistant messages and copy/insert/run it", 114 handler: async (args, ctx) => { 115 if (!ctx.hasUI && (!args || args.trim().length === 0)) { 116 return; 117 } 118 119 const parsed = parseArgs(args); 120 const snippets = collectSnippets(ctx, parsed.scope, parsed.includeInline, parsed.limit); 121 122 if (snippets.length === 0) { 123 if (ctx.hasUI) ctx.ui.notify("No code snippets found. /code tracks code blocks and filepaths only.", "warning"); 124 return; 125 } 126 127 let snippet: Snippet | undefined; 128 let pickedAction: PickResult["action"] | undefined; 129 if (parsed.index !== undefined) { 130 snippet = snippets[parsed.index]; 131 if (!snippet) { 132 if (ctx.hasUI) ctx.ui.notify("Snippet index out of range.", "warning"); 133 return; 134 } 135 } else { 136 if (!ctx.hasUI) return; 137 const result = await pickSnippet(ctx, snippets); 138 if (!result) return; 139 snippet = result.snippet; 140 pickedAction = result.action; 141 } 142 143 let action = parsed.action ?? pickedAction; 144 if (!action) { 145 if (!ctx.hasUI) return; 146 action = await pickAction(ctx); 147 } 148 if (!action) return; 149 150 if (action === "copy") { 151 const ok = await copyToClipboard(pi, snippet.content); 152 if (ctx.hasUI) { 153 ctx.ui.notify(ok ? "Copied to clipboard." : "Failed to copy to clipboard.", ok ? "info" : "error"); 154 } 155 return; 156 } 157 158 if (action === "insert") { 159 if (!ctx.hasUI) return; 160 insertIntoEditor(ctx, snippet.content); 161 ctx.ui.notify("Inserted snippet into editor.", "info"); 162 return; 163 } 164 165 if (action === "run") { 166 if (!ctx.hasUI) return; 167 const ok = await ctx.ui.confirm("Run snippet?", "This will execute the selected snippet in your shell."); 168 if (!ok) return; 169 await runSnippet(pi, ctx, snippet.content); 170 } 171 }, 172 }); 173 }