co-mono/packages/coding-agent/examples/extensions/preset.ts
Mario Zechner 59d8b7948c Add ExtensionAPI methods, preset example, and TUI documentation improvements
- ExtensionAPI: setModel(), getThinkingLevel(), setThinkingLevel() methods
- New preset.ts example with plan/implement presets for model/thinking/tools switching
- Export all UI components from pi-coding-agent for extension use
- docs/tui.md: Common Patterns section with copy-paste code for SelectList, BorderedLoader, SettingsList, setStatus, setWidget, setFooter
- docs/tui.md: Key Rules section for extension UI development
- docs/extensions.md: Exhaustive example links for all ExtensionAPI methods and events
- System prompt now references docs/tui.md for TUI development

Fixes #509, relates to #347
2026-01-06 23:24:23 +01:00

398 lines
13 KiB
TypeScript

/**
* Preset Extension
*
* Allows defining named presets that configure model, thinking level, tools,
* and system prompt instructions. Presets are defined in JSON config files
* and can be activated via CLI flag, /preset command, or Ctrl+Shift+U to cycle.
*
* Config files (merged, project takes precedence):
* - ~/.pi/agent/presets.json (global)
* - <cwd>/.pi/presets.json (project-local)
*
* Example presets.json:
* ```json
* {
* "plan": {
* "provider": "anthropic",
* "model": "claude-sonnet-4-5",
* "thinkingLevel": "high",
* "tools": ["read", "grep", "find", "ls"],
* "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)"
* },
* "implement": {
* "provider": "anthropic",
* "model": "claude-sonnet-4-5",
* "thinkingLevel": "high",
* "tools": ["read", "bash", "edit", "write"],
* "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."
* }
* }
* ```
*
* Usage:
* - `pi --preset plan` - start with plan preset
* - `/preset` - show selector to switch presets mid-session
* - `/preset implement` - switch to implement preset directly
* - `Ctrl+Shift+U` - cycle through presets
*
* CLI flags always override preset values.
*/
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import { Container, Key, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";
// Preset configuration
interface Preset {
/** Provider name (e.g., "anthropic", "openai") */
provider?: string;
/** Model ID (e.g., "claude-sonnet-4-5") */
model?: string;
/** Thinking level */
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
/** Tools to enable (replaces default set) */
tools?: string[];
/** Instructions to append to system prompt */
instructions?: string;
}
interface PresetsConfig {
[name: string]: Preset;
}
/**
* Load presets from config files.
* Project-local presets override global presets with the same name.
*/
function loadPresets(cwd: string): PresetsConfig {
const globalPath = join(homedir(), ".pi", "agent", "presets.json");
const projectPath = join(cwd, ".pi", "presets.json");
let globalPresets: PresetsConfig = {};
let projectPresets: PresetsConfig = {};
// Load global presets
if (existsSync(globalPath)) {
try {
const content = readFileSync(globalPath, "utf-8");
globalPresets = JSON.parse(content);
} catch (err) {
console.error(`Failed to load global presets from ${globalPath}: ${err}`);
}
}
// Load project presets
if (existsSync(projectPath)) {
try {
const content = readFileSync(projectPath, "utf-8");
projectPresets = JSON.parse(content);
} catch (err) {
console.error(`Failed to load project presets from ${projectPath}: ${err}`);
}
}
// Merge (project overrides global)
return { ...globalPresets, ...projectPresets };
}
export default function presetExtension(pi: ExtensionAPI) {
let presets: PresetsConfig = {};
let activePresetName: string | undefined;
let activePreset: Preset | undefined;
// Register --preset CLI flag
pi.registerFlag("preset", {
description: "Preset configuration to use",
type: "string",
});
/**
* Apply a preset configuration.
*/
async function applyPreset(name: string, preset: Preset, ctx: ExtensionContext): Promise<boolean> {
// Apply model if specified
if (preset.provider && preset.model) {
const model = ctx.modelRegistry.find(preset.provider, preset.model);
if (model) {
const success = await pi.setModel(model);
if (!success) {
ctx.ui.notify(`Preset "${name}": No API key for ${preset.provider}/${preset.model}`, "warning");
}
} else {
ctx.ui.notify(`Preset "${name}": Model ${preset.provider}/${preset.model} not found`, "warning");
}
}
// Apply thinking level if specified
if (preset.thinkingLevel) {
pi.setThinkingLevel(preset.thinkingLevel);
}
// Apply tools if specified
if (preset.tools && preset.tools.length > 0) {
const allTools = pi.getAllTools();
const validTools = preset.tools.filter((t) => allTools.includes(t));
const invalidTools = preset.tools.filter((t) => !allTools.includes(t));
if (invalidTools.length > 0) {
ctx.ui.notify(`Preset "${name}": Unknown tools: ${invalidTools.join(", ")}`, "warning");
}
if (validTools.length > 0) {
pi.setActiveTools(validTools);
}
}
// Store active preset for system prompt injection
activePresetName = name;
activePreset = preset;
return true;
}
/**
* Build description string for a preset.
*/
function buildPresetDescription(preset: Preset): string {
const parts: string[] = [];
if (preset.provider && preset.model) {
parts.push(`${preset.provider}/${preset.model}`);
}
if (preset.thinkingLevel) {
parts.push(`thinking:${preset.thinkingLevel}`);
}
if (preset.tools) {
parts.push(`tools:${preset.tools.join(",")}`);
}
if (preset.instructions) {
const truncated =
preset.instructions.length > 30 ? `${preset.instructions.slice(0, 27)}...` : preset.instructions;
parts.push(`"${truncated}"`);
}
return parts.join(" | ");
}
/**
* Show preset selector UI using custom SelectList component.
*/
async function showPresetSelector(ctx: ExtensionContext): Promise<void> {
const presetNames = Object.keys(presets);
if (presetNames.length === 0) {
ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning");
return;
}
// Build select items with descriptions
const items: SelectItem[] = presetNames.map((name) => {
const preset = presets[name];
const isActive = name === activePresetName;
return {
value: name,
label: isActive ? `${name} (active)` : name,
description: buildPresetDescription(preset),
};
});
// Add "None" option to clear preset
items.push({
value: "(none)",
label: "(none)",
description: "Clear active preset, restore defaults",
});
const result = await ctx.ui.custom<string | null>((tui, theme, done) => {
const container = new Container();
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
// Header
container.addChild(new Text(theme.fg("accent", theme.bold("Select Preset"))));
// SelectList with themed styling
const selectList = new SelectList(items, Math.min(items.length, 10), {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
});
selectList.onSelect = (item) => done(item.value);
selectList.onCancel = () => done(null);
container.addChild(selectList);
// Footer hint
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel")));
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
return {
render(width: number) {
return container.render(width);
},
invalidate() {
container.invalidate();
},
handleInput(data: string) {
selectList.handleInput(data);
tui.requestRender();
},
};
});
if (!result) return;
if (result === "(none)") {
// Clear preset and restore defaults
activePresetName = undefined;
activePreset = undefined;
pi.setActiveTools(["read", "bash", "edit", "write"]);
ctx.ui.notify("Preset cleared, defaults restored", "info");
updateStatus(ctx);
return;
}
const preset = presets[result];
if (preset) {
await applyPreset(result, preset, ctx);
ctx.ui.notify(`Preset "${result}" activated`, "info");
updateStatus(ctx);
}
}
/**
* Update status indicator.
*/
function updateStatus(ctx: ExtensionContext) {
if (activePresetName) {
ctx.ui.setStatus("preset", ctx.ui.theme.fg("accent", `preset:${activePresetName}`));
} else {
ctx.ui.setStatus("preset", undefined);
}
}
function getPresetOrder(): string[] {
return Object.keys(presets).sort();
}
async function cyclePreset(ctx: ExtensionContext): Promise<void> {
const presetNames = getPresetOrder();
if (presetNames.length === 0) {
ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning");
return;
}
const cycleList = ["(none)", ...presetNames];
const currentName = activePresetName ?? "(none)";
const currentIndex = cycleList.indexOf(currentName);
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % cycleList.length;
const nextName = cycleList[nextIndex];
if (nextName === "(none)") {
activePresetName = undefined;
activePreset = undefined;
pi.setActiveTools(["read", "bash", "edit", "write"]);
ctx.ui.notify("Preset cleared, defaults restored", "info");
updateStatus(ctx);
return;
}
const preset = presets[nextName];
if (!preset) return;
await applyPreset(nextName, preset, ctx);
ctx.ui.notify(`Preset "${nextName}" activated`, "info");
updateStatus(ctx);
}
pi.registerShortcut(Key.ctrlShift("u"), {
description: "Cycle presets",
handler: async (ctx) => {
await cyclePreset(ctx);
},
});
// Register /preset command
pi.registerCommand("preset", {
description: "Switch preset configuration",
handler: async (args, ctx) => {
// If preset name provided, apply directly
if (args?.trim()) {
const name = args.trim();
const preset = presets[name];
if (!preset) {
const available = Object.keys(presets).join(", ") || "(none defined)";
ctx.ui.notify(`Unknown preset "${name}". Available: ${available}`, "error");
return;
}
await applyPreset(name, preset, ctx);
ctx.ui.notify(`Preset "${name}" activated`, "info");
updateStatus(ctx);
return;
}
// Otherwise show selector
await showPresetSelector(ctx);
},
});
// Inject preset instructions into system prompt
pi.on("before_agent_start", async () => {
if (activePreset?.instructions) {
return {
systemPromptAppend: activePreset.instructions,
};
}
});
// Initialize on session start
pi.on("session_start", async (_event, ctx) => {
// Load presets from config files
presets = loadPresets(ctx.cwd);
// Check for --preset flag
const presetFlag = pi.getFlag("preset");
if (typeof presetFlag === "string" && presetFlag) {
const preset = presets[presetFlag];
if (preset) {
await applyPreset(presetFlag, preset, ctx);
ctx.ui.notify(`Preset "${presetFlag}" activated`, "info");
} else {
const available = Object.keys(presets).join(", ") || "(none defined)";
ctx.ui.notify(`Unknown preset "${presetFlag}". Available: ${available}`, "warning");
}
}
// Restore preset from session state
const entries = ctx.sessionManager.getEntries();
const presetEntry = entries
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "preset-state")
.pop() as { data?: { name: string } } | undefined;
if (presetEntry?.data?.name && !presetFlag) {
const preset = presets[presetEntry.data.name];
if (preset) {
activePresetName = presetEntry.data.name;
activePreset = preset;
// Don't re-apply model/tools on restore, just keep the name for instructions
}
}
updateStatus(ctx);
});
// Persist preset state
pi.on("turn_start", async () => {
if (activePresetName) {
pi.appendEntry("preset-state", { name: activePresetName });
}
});
}