mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 18:01:22 +00:00
398 lines
13 KiB
TypeScript
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": "openai-codex",
|
|
* "model": "gpt-5.2-codex",
|
|
* "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 });
|
|
}
|
|
});
|
|
}
|