/ extensions / preset.ts
preset.ts
1 /** 2 * Preset Extension 3 * 4 * Allows defining named presets that configure model, thinking level, tools, 5 * and system prompt instructions. Presets are defined in JSON config files 6 * and can be activated via CLI flag, /preset command, or Ctrl+Shift+U to cycle. 7 * 8 * Config files (merged, project takes precedence): 9 * - ~/.pi/agent/presets.json (global) 10 * - <cwd>/.pi/presets.json (project-local) 11 * 12 * Example presets.json: 13 * ```json 14 * { 15 * "plan": { 16 * "provider": "openai-codex", 17 * "model": "gpt-5.2-codex", 18 * "thinkingLevel": "high", 19 * "tools": ["read", "grep", "find", "ls"], 20 * "instructions": "You are in PLANNING MODE. Your job is to deeply understand the problem and create a detailed implementation plan.\n\nRules:\n- DO NOT make any changes. You cannot edit or write files.\n- Read files IN FULL (no offset/limit) to get complete context. Partial reads miss critical details.\n- Explore thoroughly: grep for related code, find similar patterns, understand the architecture.\n- Ask clarifying questions if requirements are ambiguous. Do not assume.\n- Identify risks, edge cases, and dependencies before proposing solutions.\n\nOutput:\n- Create a structured plan with numbered steps.\n- For each step: what to change, why, and potential risks.\n- List files that will be modified.\n- Note any tests that should be added or updated.\n\nWhen done, ask the user if they want you to:\n1. Write the plan to a markdown file (e.g., PLAN.md)\n2. Create a GitHub issue with the plan\n3. Proceed to implementation (they should switch to 'implement' preset)" 21 * }, 22 * "implement": { 23 * "provider": "anthropic", 24 * "model": "claude-sonnet-4-5", 25 * "thinkingLevel": "high", 26 * "tools": ["read", "bash", "edit", "write"], 27 * "instructions": "You are in IMPLEMENTATION MODE. Your job is to make focused, correct changes.\n\nRules:\n- Keep scope tight. Do exactly what was asked, no more.\n- Read files before editing to understand current state.\n- Make surgical edits. Prefer edit over write for existing files.\n- Explain your reasoning briefly before each change.\n- Run tests or type checks after changes if the project has them (npm test, npm run check, etc.).\n- If you encounter unexpected complexity, STOP and explain the issue rather than hacking around it.\n\nIf no plan exists:\n- Ask clarifying questions before starting.\n- Propose what you'll do and get confirmation for non-trivial changes.\n\nAfter completing changes:\n- Summarize what was done.\n- Note any follow-up work or tests that should be added." 28 * } 29 * } 30 * ``` 31 * 32 * Usage: 33 * - `pi --preset plan` - start with plan preset 34 * - `/preset` - show selector to switch presets mid-session 35 * - `/preset implement` - switch to implement preset directly 36 * - `Ctrl+Shift+U` - cycle through presets 37 * 38 * CLI flags always override preset values. 39 */ 40 41 import { existsSync, readFileSync } from "node:fs"; 42 import { homedir } from "node:os"; 43 import { join } from "node:path"; 44 import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; 45 import { DynamicBorder } from "@mariozechner/pi-coding-agent"; 46 import { Container, Key, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui"; 47 48 // Preset configuration 49 interface Preset { 50 /** Provider name (e.g., "anthropic", "openai") */ 51 provider?: string; 52 /** Model ID (e.g., "claude-sonnet-4-5") */ 53 model?: string; 54 /** Thinking level */ 55 thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; 56 /** Tools to enable (replaces default set) */ 57 tools?: string[]; 58 /** Instructions to append to system prompt */ 59 instructions?: string; 60 } 61 62 interface PresetsConfig { 63 [name: string]: Preset; 64 } 65 66 /** 67 * Load presets from config files. 68 * Project-local presets override global presets with the same name. 69 */ 70 function loadPresets(cwd: string): PresetsConfig { 71 const globalPath = join(homedir(), ".pi", "agent", "presets.json"); 72 const projectPath = join(cwd, ".pi", "presets.json"); 73 74 let globalPresets: PresetsConfig = {}; 75 let projectPresets: PresetsConfig = {}; 76 77 // Load global presets 78 if (existsSync(globalPath)) { 79 try { 80 const content = readFileSync(globalPath, "utf-8"); 81 globalPresets = JSON.parse(content); 82 } catch (err) { 83 console.error(`Failed to load global presets from ${globalPath}: ${err}`); 84 } 85 } 86 87 // Load project presets 88 if (existsSync(projectPath)) { 89 try { 90 const content = readFileSync(projectPath, "utf-8"); 91 projectPresets = JSON.parse(content); 92 } catch (err) { 93 console.error(`Failed to load project presets from ${projectPath}: ${err}`); 94 } 95 } 96 97 // Merge (project overrides global) 98 return { ...globalPresets, ...projectPresets }; 99 } 100 101 export default function presetExtension(pi: ExtensionAPI) { 102 let presets: PresetsConfig = {}; 103 let activePresetName: string | undefined; 104 let activePreset: Preset | undefined; 105 106 // Register --preset CLI flag 107 pi.registerFlag("preset", { 108 description: "Preset configuration to use", 109 type: "string", 110 }); 111 112 /** 113 * Apply a preset configuration. 114 */ 115 async function applyPreset(name: string, preset: Preset, ctx: ExtensionContext): Promise<boolean> { 116 // Apply model if specified 117 if (preset.provider && preset.model) { 118 const model = ctx.modelRegistry.find(preset.provider, preset.model); 119 if (model) { 120 const success = await pi.setModel(model); 121 if (!success) { 122 ctx.ui.notify(`Preset "${name}": No API key for ${preset.provider}/${preset.model}`, "warning"); 123 } 124 } else { 125 ctx.ui.notify(`Preset "${name}": Model ${preset.provider}/${preset.model} not found`, "warning"); 126 } 127 } 128 129 // Apply thinking level if specified 130 if (preset.thinkingLevel) { 131 pi.setThinkingLevel(preset.thinkingLevel); 132 } 133 134 // Apply tools if specified 135 if (preset.tools && preset.tools.length > 0) { 136 const allToolNames = pi.getAllTools().map((t) => t.name); 137 const validTools = preset.tools.filter((t) => allToolNames.includes(t)); 138 const invalidTools = preset.tools.filter((t) => !allToolNames.includes(t)); 139 140 if (invalidTools.length > 0) { 141 ctx.ui.notify(`Preset "${name}": Unknown tools: ${invalidTools.join(", ")}`, "warning"); 142 } 143 144 if (validTools.length > 0) { 145 pi.setActiveTools(validTools); 146 } 147 } 148 149 // Store active preset for system prompt injection 150 activePresetName = name; 151 activePreset = preset; 152 153 return true; 154 } 155 156 /** 157 * Build description string for a preset. 158 */ 159 function buildPresetDescription(preset: Preset): string { 160 const parts: string[] = []; 161 162 if (preset.provider && preset.model) { 163 parts.push(`${preset.provider}/${preset.model}`); 164 } 165 if (preset.thinkingLevel) { 166 parts.push(`thinking:${preset.thinkingLevel}`); 167 } 168 if (preset.tools) { 169 parts.push(`tools:${preset.tools.join(",")}`); 170 } 171 if (preset.instructions) { 172 const truncated = 173 preset.instructions.length > 30 ? `${preset.instructions.slice(0, 27)}...` : preset.instructions; 174 parts.push(`"${truncated}"`); 175 } 176 177 return parts.join(" | "); 178 } 179 180 /** 181 * Show preset selector UI using custom SelectList component. 182 */ 183 async function showPresetSelector(ctx: ExtensionContext): Promise<void> { 184 const presetNames = Object.keys(presets); 185 186 if (presetNames.length === 0) { 187 ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning"); 188 return; 189 } 190 191 // Build select items with descriptions 192 const items: SelectItem[] = presetNames.map((name) => { 193 const preset = presets[name]; 194 const isActive = name === activePresetName; 195 return { 196 value: name, 197 label: isActive ? `${name} (active)` : name, 198 description: buildPresetDescription(preset), 199 }; 200 }); 201 202 // Add "None" option to clear preset 203 items.push({ 204 value: "(none)", 205 label: "(none)", 206 description: "Clear active preset, restore defaults", 207 }); 208 209 const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => { 210 const container = new Container(); 211 container.addChild(new DynamicBorder((str) => theme.fg("accent", str))); 212 213 // Header 214 container.addChild(new Text(theme.fg("accent", theme.bold("Select Preset")))); 215 216 // SelectList with themed styling 217 const selectList = new SelectList(items, Math.min(items.length, 10), { 218 selectedPrefix: (text) => theme.fg("accent", text), 219 selectedText: (text) => theme.fg("accent", text), 220 description: (text) => theme.fg("muted", text), 221 scrollInfo: (text) => theme.fg("dim", text), 222 noMatch: (text) => theme.fg("warning", text), 223 }); 224 225 selectList.onSelect = (item) => done(item.value); 226 selectList.onCancel = () => done(null); 227 228 container.addChild(selectList); 229 230 // Footer hint 231 container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"))); 232 233 container.addChild(new DynamicBorder((str) => theme.fg("accent", str))); 234 235 return { 236 render(width: number) { 237 return container.render(width); 238 }, 239 invalidate() { 240 container.invalidate(); 241 }, 242 handleInput(data: string) { 243 selectList.handleInput(data); 244 tui.requestRender(); 245 }, 246 }; 247 }); 248 249 if (!result) return; 250 251 if (result === "(none)") { 252 // Clear preset and restore defaults 253 activePresetName = undefined; 254 activePreset = undefined; 255 pi.setActiveTools(["read", "bash", "edit", "write"]); 256 ctx.ui.notify("Preset cleared, defaults restored", "info"); 257 updateStatus(ctx); 258 return; 259 } 260 261 const preset = presets[result]; 262 if (preset) { 263 await applyPreset(result, preset, ctx); 264 ctx.ui.notify(`Preset "${result}" activated`, "info"); 265 updateStatus(ctx); 266 } 267 } 268 269 /** 270 * Update status indicator. 271 */ 272 function updateStatus(ctx: ExtensionContext) { 273 if (activePresetName) { 274 ctx.ui.setStatus("preset", ctx.ui.theme.fg("accent", `preset:${activePresetName}`)); 275 } else { 276 ctx.ui.setStatus("preset", undefined); 277 } 278 } 279 280 function getPresetOrder(): string[] { 281 return Object.keys(presets).sort(); 282 } 283 284 async function cyclePreset(ctx: ExtensionContext): Promise<void> { 285 const presetNames = getPresetOrder(); 286 if (presetNames.length === 0) { 287 ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning"); 288 return; 289 } 290 291 const cycleList = ["(none)", ...presetNames]; 292 const currentName = activePresetName ?? "(none)"; 293 const currentIndex = cycleList.indexOf(currentName); 294 const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % cycleList.length; 295 const nextName = cycleList[nextIndex]; 296 297 if (nextName === "(none)") { 298 activePresetName = undefined; 299 activePreset = undefined; 300 pi.setActiveTools(["read", "bash", "edit", "write"]); 301 ctx.ui.notify("Preset cleared, defaults restored", "info"); 302 updateStatus(ctx); 303 return; 304 } 305 306 const preset = presets[nextName]; 307 if (!preset) return; 308 309 await applyPreset(nextName, preset, ctx); 310 ctx.ui.notify(`Preset "${nextName}" activated`, "info"); 311 updateStatus(ctx); 312 } 313 314 pi.registerShortcut(Key.ctrlShift("u"), { 315 description: "Cycle presets", 316 handler: async (ctx) => { 317 await cyclePreset(ctx); 318 }, 319 }); 320 321 // Register /preset command 322 pi.registerCommand("preset", { 323 description: "Switch preset configuration", 324 handler: async (args, ctx) => { 325 // If preset name provided, apply directly 326 if (args?.trim()) { 327 const name = args.trim(); 328 const preset = presets[name]; 329 330 if (!preset) { 331 const available = Object.keys(presets).join(", ") || "(none defined)"; 332 ctx.ui.notify(`Unknown preset "${name}". Available: ${available}`, "error"); 333 return; 334 } 335 336 await applyPreset(name, preset, ctx); 337 ctx.ui.notify(`Preset "${name}" activated`, "info"); 338 updateStatus(ctx); 339 return; 340 } 341 342 // Otherwise show selector 343 await showPresetSelector(ctx); 344 }, 345 }); 346 347 // Inject preset instructions into system prompt 348 pi.on("before_agent_start", async (event) => { 349 if (activePreset?.instructions) { 350 return { 351 systemPrompt: `${event.systemPrompt}\n\n${activePreset.instructions}`, 352 }; 353 } 354 }); 355 356 // Initialize on session start 357 pi.on("session_start", async (_event, ctx) => { 358 // Load presets from config files 359 presets = loadPresets(ctx.cwd); 360 361 // Check for --preset flag 362 const presetFlag = pi.getFlag("preset"); 363 if (typeof presetFlag === "string" && presetFlag) { 364 const preset = presets[presetFlag]; 365 if (preset) { 366 await applyPreset(presetFlag, preset, ctx); 367 ctx.ui.notify(`Preset "${presetFlag}" activated`, "info"); 368 } else { 369 const available = Object.keys(presets).join(", ") || "(none defined)"; 370 ctx.ui.notify(`Unknown preset "${presetFlag}". Available: ${available}`, "warning"); 371 } 372 } 373 374 // Restore preset from session state 375 const entries = ctx.sessionManager.getEntries(); 376 const presetEntry = entries 377 .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "preset-state") 378 .pop() as { data?: { name: string } } | undefined; 379 380 if (presetEntry?.data?.name && !presetFlag) { 381 const preset = presets[presetEntry.data.name]; 382 if (preset) { 383 activePresetName = presetEntry.data.name; 384 activePreset = preset; 385 // Don't re-apply model/tools on restore, just keep the name for instructions 386 } 387 } 388 389 updateStatus(ctx); 390 }); 391 392 // Persist preset state 393 pi.on("turn_start", async () => { 394 if (activePresetName) { 395 pi.appendEntry("preset-state", { name: activePresetName }); 396 } 397 }); 398 }