mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 17:00:59 +00:00
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
This commit is contained in:
parent
c35a18b2b3
commit
59d8b7948c
14 changed files with 850 additions and 13 deletions
|
|
@ -36,6 +36,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
|||
|
||||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| `preset.ts` | Named presets for model, thinking level, tools, and instructions via `--preset` flag and `/preset` command |
|
||||
| `plan-mode.ts` | Claude Code-style plan mode for read-only exploration with `/plan` command |
|
||||
| `tools.ts` | Interactive `/tools` command to enable/disable tools with session persistence |
|
||||
| `handoff.ts` | Transfer context to a new focused session via `/handoff <goal>` |
|
||||
|
|
|
|||
398
packages/coding-agent/examples/extensions/preset.ts
Normal file
398
packages/coding-agent/examples/extensions/preset.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
/**
|
||||
* 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue