forked from earendil-works/pi
-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathpreset.ts
More file actions
430 lines (374 loc) · 14 KB
/
Copy pathpreset.ts
File metadata and controls
430 lines (374 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
/**
* 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 { join } from "node:path";
import type { Api, Model } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
import { Container, Key, type SelectItem, SelectList, Text } from "@earendil-works/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(getAgentDir(), "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 };
}
interface OriginalState {
model: Model<Api> | undefined;
thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
tools: string[];
}
export default function presetExtension(pi: ExtensionAPI) {
let presets: PresetsConfig = {};
let activePresetName: string | undefined;
let activePreset: Preset | undefined;
let originalState: OriginalState | 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> {
// Snapshot state before the first preset is applied (i.e. only when transitioning from no-preset)
if (activePresetName === undefined) {
originalState = {
model: ctx.model,
thinkingLevel: pi.getThinkingLevel(),
tools: pi.getActiveTools(),
};
}
// 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 allToolNames = pi.getAllTools().map((t) => t.name);
const validTools = preset.tools.filter((t) => allToolNames.includes(t));
const invalidTools = preset.tools.filter((t) => !allToolNames.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, _kb, 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 original state
activePresetName = undefined;
activePreset = undefined;
if (originalState) {
if (originalState.model) {
await pi.setModel(originalState.model);
}
pi.setThinkingLevel(originalState.thinkingLevel);
pi.setActiveTools(originalState.tools);
} else {
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;
if (originalState) {
if (originalState.model) {
await pi.setModel(originalState.model);
}
pi.setThinkingLevel(originalState.thinkingLevel);
pi.setActiveTools(originalState.tools);
} else {
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 (event) => {
if (activePreset?.instructions) {
return {
systemPrompt: `${event.systemPrompt}\n\n${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 });
}
});
}