/ 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  }