@sherif-fanous/pi-presets-plus 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/package.json +74 -0
- package/src/activation/active-state.ts +25 -0
- package/src/activation/apply.ts +236 -0
- package/src/activation/baseline.ts +32 -0
- package/src/activation/clear.ts +434 -0
- package/src/activation/dirty.ts +69 -0
- package/src/activation/drift-handlers.ts +71 -0
- package/src/activation/drift.ts +77 -0
- package/src/activation/same-set.ts +32 -0
- package/src/activation/state-matches.ts +29 -0
- package/src/activation/thinking.ts +54 -0
- package/src/commands/presets/clear.ts +18 -0
- package/src/commands/presets/index.ts +9 -0
- package/src/commands/presets/notify.ts +22 -0
- package/src/commands/presets/reload.ts +28 -0
- package/src/commands/presets/router.ts +139 -0
- package/src/commands/presets/status.ts +262 -0
- package/src/flag.ts +88 -0
- package/src/hotkey-conflicts.ts +136 -0
- package/src/hotkey-reload-baseline.ts +112 -0
- package/src/hotkeys.ts +104 -0
- package/src/index.ts +171 -0
- package/src/messages.ts +34 -0
- package/src/store/api.ts +262 -0
- package/src/store/load.ts +175 -0
- package/src/store/merge.ts +69 -0
- package/src/store/paths.ts +38 -0
- package/src/store/save.ts +75 -0
- package/src/store/validate.ts +195 -0
- package/src/types.ts +169 -0
- package/src/ui/confirm.ts +126 -0
- package/src/ui/editor.ts +1617 -0
- package/src/ui/filter.ts +79 -0
- package/src/ui/frame.ts +109 -0
- package/src/ui/hotkey-input.ts +242 -0
- package/src/ui/info-dialog.ts +118 -0
- package/src/ui/labels.ts +51 -0
- package/src/ui/picker-state.ts +151 -0
- package/src/ui/picker.ts +982 -0
- package/src/ui/reload-prompt.ts +59 -0
- package/src/ui/status.ts +55 -0
- package/src/ui/widgets.ts +274 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Active-preset clear flow.
|
|
3
|
+
*
|
|
4
|
+
* Owns restoring pi state from the baseline overlay (with user-override
|
|
5
|
+
* protection) and rendering a user-visible summary; it does NOT own apply,
|
|
6
|
+
* picker UI, or status formatting beyond its own summary.
|
|
7
|
+
*/
|
|
8
|
+
import type { ActivePresetState, ThinkingLevel } from "../types.js";
|
|
9
|
+
import {
|
|
10
|
+
CLEAR_DIALOG_TITLE,
|
|
11
|
+
MODEL_LABEL,
|
|
12
|
+
THINKING_LABEL,
|
|
13
|
+
TOOLS_LABEL,
|
|
14
|
+
} from "../ui/labels.js";
|
|
15
|
+
import { updateStatus } from "../ui/status.js";
|
|
16
|
+
import { clearActive, getActive } from "./active-state.js";
|
|
17
|
+
import { withSelfTriggeredModelSet } from "./apply.js";
|
|
18
|
+
import { sameSet } from "./same-set.js";
|
|
19
|
+
import type {
|
|
20
|
+
ExtensionAPI,
|
|
21
|
+
ExtensionCommandContext,
|
|
22
|
+
Theme,
|
|
23
|
+
} from "@mariozechner/pi-coding-agent";
|
|
24
|
+
|
|
25
|
+
export interface ClearDecision {
|
|
26
|
+
readonly parts: readonly ClearPart[];
|
|
27
|
+
readonly writes: ClearWrites;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ClearPart {
|
|
31
|
+
readonly action: ClearAction;
|
|
32
|
+
/** Tools that were dropped because they no longer exist (restored-partial only). */
|
|
33
|
+
readonly dropped?: readonly string[];
|
|
34
|
+
readonly field: ClearField;
|
|
35
|
+
/**
|
|
36
|
+
* The value to render after the field label.
|
|
37
|
+
*
|
|
38
|
+
* - For `restored` / `already-baseline` / `restored-partial`: the baseline
|
|
39
|
+
* value (which is what the row reports as the post-clear state).
|
|
40
|
+
* - For `user-override` / `not-owned` / `baseline-null` / `unknown`: the
|
|
41
|
+
* user's *current* value (which the clear left untouched).
|
|
42
|
+
* - For `restore-failed`: the baseline value we tried (and failed) to
|
|
43
|
+
* reach; the renderer wraps it as "could not switch back to …".
|
|
44
|
+
*/
|
|
45
|
+
readonly value: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ClearSnapshot {
|
|
49
|
+
readonly active: ActivePresetState;
|
|
50
|
+
readonly allTools: readonly string[];
|
|
51
|
+
readonly currentModel: { provider: string; id: string } | null;
|
|
52
|
+
readonly currentThinking: ThinkingLevel;
|
|
53
|
+
readonly currentTools: readonly string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ClearWrites {
|
|
57
|
+
readonly model?: { provider: string; id: string };
|
|
58
|
+
readonly thinkingLevel?: ThinkingLevel;
|
|
59
|
+
readonly tools?: readonly string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type ClearAction =
|
|
63
|
+
| "already-baseline"
|
|
64
|
+
| "baseline-null"
|
|
65
|
+
| "not-owned"
|
|
66
|
+
| "restore-failed"
|
|
67
|
+
| "restored"
|
|
68
|
+
| "restored-partial"
|
|
69
|
+
| "unknown"
|
|
70
|
+
| "user-override";
|
|
71
|
+
|
|
72
|
+
export type ClearField = "model" | "thinking" | "tools";
|
|
73
|
+
|
|
74
|
+
interface Styler {
|
|
75
|
+
bold(text: string): string;
|
|
76
|
+
fg(color: Parameters<Theme["fg"]>[0], text: string): string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const FIELD_LABELS: Record<ClearField, string> = {
|
|
80
|
+
model: MODEL_LABEL,
|
|
81
|
+
thinking: THINKING_LABEL,
|
|
82
|
+
tools: TOOLS_LABEL,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const IDENTITY_STYLER: Styler = {
|
|
86
|
+
bold: (text) => text,
|
|
87
|
+
fg: (_color, text) => text,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export interface ClearResult {
|
|
91
|
+
readonly name: string;
|
|
92
|
+
readonly parts: readonly ClearPart[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Choose the plain-English lead sentence that sits under the title.
|
|
97
|
+
*
|
|
98
|
+
* The sentence describes the overall disposition so the per-row values
|
|
99
|
+
* underneath can stay short. Decision priority (most specific first):
|
|
100
|
+
*
|
|
101
|
+
* 1. Every field is `unknown` (priorUnknown branch) — no baseline saved.
|
|
102
|
+
* 2. Any field failed to restore — surface the problem in the lead.
|
|
103
|
+
* 3. Every field already matched baseline — nothing was actually written.
|
|
104
|
+
* 4. Every field is restore-like (restored / restored-partial /
|
|
105
|
+
* already-baseline) — the happy path; mention unavailable tools if any.
|
|
106
|
+
* 5. Every field was kept (user-override / not-owned / baseline-null) —
|
|
107
|
+
* preset turned off but no baseline values were applicable.
|
|
108
|
+
* 6. Otherwise it's a mixed result.
|
|
109
|
+
*/
|
|
110
|
+
export function chooseClearLead(parts: readonly ClearPart[]): string {
|
|
111
|
+
if (parts.every((part) => part.action === "unknown")) {
|
|
112
|
+
return "No saved baseline. Current settings were left as-is.";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (parts.some((part) => part.action === "restore-failed")) {
|
|
116
|
+
return "Tried to restore your previous settings but ran into a problem.";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (parts.every((part) => part.action === "already-baseline")) {
|
|
120
|
+
return "Your settings already matched the saved baseline.";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (parts.every((part) => isRestoreLike(part.action))) {
|
|
124
|
+
return parts.some((part) => part.action === "restored-partial")
|
|
125
|
+
? "Restored your previous settings. Some tools are no longer available."
|
|
126
|
+
: "Restored your previous settings.";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (parts.every((part) => isKeptLike(part.action))) {
|
|
130
|
+
return "Kept all your manual changes. Nothing to restore.";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return "Restored some settings. Kept your manual changes for others.";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function clear(
|
|
137
|
+
ctx: ExtensionCommandContext,
|
|
138
|
+
pi: ExtensionAPI,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
const result = await clearReturning(ctx, pi);
|
|
141
|
+
|
|
142
|
+
ctx.ui.notify(
|
|
143
|
+
result
|
|
144
|
+
? renderClearSummary(result.name, result.parts, ctx.ui.theme)
|
|
145
|
+
: "No preset is active.",
|
|
146
|
+
"info",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function clearReturning(
|
|
151
|
+
ctx: ExtensionCommandContext,
|
|
152
|
+
pi: ExtensionAPI,
|
|
153
|
+
): Promise<ClearResult | undefined> {
|
|
154
|
+
const active = getActive();
|
|
155
|
+
|
|
156
|
+
if (!active) return undefined;
|
|
157
|
+
|
|
158
|
+
const currentModel = ctx.model
|
|
159
|
+
? { provider: ctx.model.provider, id: ctx.model.id }
|
|
160
|
+
: null;
|
|
161
|
+
const decision = decideClear({
|
|
162
|
+
active,
|
|
163
|
+
allTools: pi.getAllTools().map((tool) => tool.name),
|
|
164
|
+
currentModel,
|
|
165
|
+
currentThinking: pi.getThinkingLevel(),
|
|
166
|
+
currentTools: pi.getActiveTools(),
|
|
167
|
+
});
|
|
168
|
+
const finalParts = await executeClear(decision, ctx, pi);
|
|
169
|
+
|
|
170
|
+
clearActive();
|
|
171
|
+
pi.appendEntry("presets-plus:active", { name: null });
|
|
172
|
+
updateStatus(ctx, getActive(), () => undefined);
|
|
173
|
+
|
|
174
|
+
return { name: active.name, parts: finalParts };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function decideClear(snapshot: ClearSnapshot): ClearDecision {
|
|
178
|
+
const { active } = snapshot;
|
|
179
|
+
const currentModelDisplay = formatModel(snapshot.currentModel);
|
|
180
|
+
const currentToolsDisplay = formatTools(snapshot.currentTools);
|
|
181
|
+
|
|
182
|
+
if (active.restore.kind === "unknown") {
|
|
183
|
+
return {
|
|
184
|
+
parts: [
|
|
185
|
+
{ action: "unknown", field: "model", value: currentModelDisplay },
|
|
186
|
+
{
|
|
187
|
+
action: "unknown",
|
|
188
|
+
field: "thinking",
|
|
189
|
+
value: snapshot.currentThinking,
|
|
190
|
+
},
|
|
191
|
+
{ action: "unknown", field: "tools", value: currentToolsDisplay },
|
|
192
|
+
],
|
|
193
|
+
writes: {},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const parts: ClearPart[] = [];
|
|
198
|
+
const writes: {
|
|
199
|
+
-readonly [K in keyof ClearWrites]: ClearWrites[K];
|
|
200
|
+
} = {};
|
|
201
|
+
const { baseline, lastApplied, owned } = active.restore;
|
|
202
|
+
|
|
203
|
+
if (sameModel(snapshot.currentModel, baseline.model)) {
|
|
204
|
+
parts.push({
|
|
205
|
+
action: "already-baseline",
|
|
206
|
+
field: "model",
|
|
207
|
+
value: formatModel(baseline.model),
|
|
208
|
+
});
|
|
209
|
+
} else if (sameModel(snapshot.currentModel, lastApplied.model)) {
|
|
210
|
+
if (baseline.model) {
|
|
211
|
+
writes.model = baseline.model;
|
|
212
|
+
parts.push({
|
|
213
|
+
action: "restored",
|
|
214
|
+
field: "model",
|
|
215
|
+
value: formatModel(baseline.model),
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
parts.push({
|
|
219
|
+
action: "baseline-null",
|
|
220
|
+
field: "model",
|
|
221
|
+
value: currentModelDisplay,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
parts.push({
|
|
226
|
+
action: "user-override",
|
|
227
|
+
field: "model",
|
|
228
|
+
value: currentModelDisplay,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (snapshot.currentThinking === baseline.thinkingLevel) {
|
|
233
|
+
parts.push({
|
|
234
|
+
action: "already-baseline",
|
|
235
|
+
field: "thinking",
|
|
236
|
+
value: baseline.thinkingLevel,
|
|
237
|
+
});
|
|
238
|
+
} else if (snapshot.currentThinking === lastApplied.thinkingLevel) {
|
|
239
|
+
writes.thinkingLevel = baseline.thinkingLevel;
|
|
240
|
+
parts.push({
|
|
241
|
+
action: "restored",
|
|
242
|
+
field: "thinking",
|
|
243
|
+
value: baseline.thinkingLevel,
|
|
244
|
+
});
|
|
245
|
+
} else {
|
|
246
|
+
parts.push({
|
|
247
|
+
action: "user-override",
|
|
248
|
+
field: "thinking",
|
|
249
|
+
value: snapshot.currentThinking,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!owned.tools) {
|
|
254
|
+
parts.push({
|
|
255
|
+
action: "not-owned",
|
|
256
|
+
field: "tools",
|
|
257
|
+
value: currentToolsDisplay,
|
|
258
|
+
});
|
|
259
|
+
} else {
|
|
260
|
+
const lastAppliedTools = lastApplied.tools ?? [];
|
|
261
|
+
|
|
262
|
+
if (sameSet(snapshot.currentTools, baseline.tools)) {
|
|
263
|
+
parts.push({
|
|
264
|
+
action: "already-baseline",
|
|
265
|
+
field: "tools",
|
|
266
|
+
value: formatTools(baseline.tools),
|
|
267
|
+
});
|
|
268
|
+
} else if (sameSet(snapshot.currentTools, lastAppliedTools)) {
|
|
269
|
+
const available = new Set(snapshot.allTools);
|
|
270
|
+
const filtered = baseline.tools.filter((toolName) =>
|
|
271
|
+
available.has(toolName),
|
|
272
|
+
);
|
|
273
|
+
const dropped = baseline.tools.filter(
|
|
274
|
+
(toolName) => !available.has(toolName),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
writes.tools = filtered;
|
|
278
|
+
parts.push({
|
|
279
|
+
action: dropped.length > 0 ? "restored-partial" : "restored",
|
|
280
|
+
dropped: dropped.length > 0 ? dropped : undefined,
|
|
281
|
+
field: "tools",
|
|
282
|
+
value: formatTools(filtered),
|
|
283
|
+
});
|
|
284
|
+
} else {
|
|
285
|
+
parts.push({
|
|
286
|
+
action: "user-override",
|
|
287
|
+
field: "tools",
|
|
288
|
+
value: currentToolsDisplay,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { parts, writes };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function renderClearSummary(
|
|
297
|
+
name: string,
|
|
298
|
+
parts: readonly ClearPart[],
|
|
299
|
+
styler: Pick<Theme, "bold" | "fg"> = IDENTITY_STYLER,
|
|
300
|
+
): string {
|
|
301
|
+
const safeStyler = normalizeStyler(styler);
|
|
302
|
+
const labels = parts.map((part) => `${FIELD_LABELS[part.field]}:`);
|
|
303
|
+
const labelWidth = Math.max(...labels.map((label) => label.length));
|
|
304
|
+
const title = safeStyler.bold(
|
|
305
|
+
safeStyler.fg("accent", `${CLEAR_DIALOG_TITLE}: ${name}`),
|
|
306
|
+
);
|
|
307
|
+
const lead = chooseClearLead(parts);
|
|
308
|
+
const rows = parts.map((part) => {
|
|
309
|
+
const label = `${FIELD_LABELS[part.field]}:`;
|
|
310
|
+
const padding = " ".repeat(labelWidth - label.length);
|
|
311
|
+
|
|
312
|
+
return ` ${safeStyler.fg("muted", label)}${padding} ${formatRowValue(part)}`;
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
return [title, lead, ...rows].join("\n");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function executeClear(
|
|
319
|
+
decision: ClearDecision,
|
|
320
|
+
ctx: Pick<ExtensionCommandContext, "modelRegistry">,
|
|
321
|
+
pi: Pick<ExtensionAPI, "setActiveTools" | "setModel" | "setThinkingLevel">,
|
|
322
|
+
): Promise<ClearPart[]> {
|
|
323
|
+
const parts = decision.parts.map((part) => ({ ...part }));
|
|
324
|
+
|
|
325
|
+
if (decision.writes.model) {
|
|
326
|
+
const target = decision.writes.model;
|
|
327
|
+
const model = ctx.modelRegistry.find(target.provider, target.id);
|
|
328
|
+
const ok = model
|
|
329
|
+
? await withSelfTriggeredModelSet(() => pi.setModel(model))
|
|
330
|
+
: false;
|
|
331
|
+
|
|
332
|
+
if (!ok) {
|
|
333
|
+
const index = parts.findIndex((part) => part.field === "model");
|
|
334
|
+
|
|
335
|
+
if (index >= 0) {
|
|
336
|
+
parts[index] = {
|
|
337
|
+
action: "restore-failed",
|
|
338
|
+
field: "model",
|
|
339
|
+
value: `${target.provider}/${target.id}`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (decision.writes.thinkingLevel !== undefined) {
|
|
346
|
+
pi.setThinkingLevel(decision.writes.thinkingLevel);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (decision.writes.tools !== undefined) {
|
|
350
|
+
pi.setActiveTools([...decision.writes.tools]);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return parts;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function formatModel(model: { provider: string; id: string } | null): string {
|
|
357
|
+
return model ? `${model.provider}/${model.id}` : "none";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Render the post-colon body for a single field row.
|
|
362
|
+
*
|
|
363
|
+
* The vocabulary intentionally parallels `formatStatus` so a user reading
|
|
364
|
+
* `/presets status` and then `/presets clear <name>` sees the same phrases
|
|
365
|
+
* for manual overrides / "not managed by …" /
|
|
366
|
+
* "no baseline saved for this field". Restored / already-baseline rows
|
|
367
|
+
* stay bare — the lead sentence above the rows already explains the
|
|
368
|
+
* disposition.
|
|
369
|
+
*/
|
|
370
|
+
function formatRowValue(part: ClearPart): string {
|
|
371
|
+
switch (part.action) {
|
|
372
|
+
case "already-baseline":
|
|
373
|
+
case "restored":
|
|
374
|
+
return part.value;
|
|
375
|
+
|
|
376
|
+
case "baseline-null":
|
|
377
|
+
case "unknown":
|
|
378
|
+
return `${part.value} (No baseline saved for this field)`;
|
|
379
|
+
|
|
380
|
+
case "not-owned":
|
|
381
|
+
return `${part.value} (Not managed by cleared preset)`;
|
|
382
|
+
|
|
383
|
+
case "restore-failed":
|
|
384
|
+
return `Could not switch back to ${part.value}.`;
|
|
385
|
+
|
|
386
|
+
case "restored-partial":
|
|
387
|
+
return part.dropped && part.dropped.length > 0
|
|
388
|
+
? `${part.value} (Unavailable: ${part.dropped.join(", ")})`
|
|
389
|
+
: part.value;
|
|
390
|
+
|
|
391
|
+
case "user-override":
|
|
392
|
+
return `${part.value} (Left as-is — you changed it after activation)`;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function formatTools(tools: readonly string[]): string {
|
|
397
|
+
return tools.length > 0 ? tools.join(", ") : "none";
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function isKeptLike(action: ClearAction): boolean {
|
|
401
|
+
return (
|
|
402
|
+
action === "user-override" ||
|
|
403
|
+
action === "not-owned" ||
|
|
404
|
+
action === "baseline-null"
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function isRestoreLike(action: ClearAction): boolean {
|
|
409
|
+
return (
|
|
410
|
+
action === "restored" ||
|
|
411
|
+
action === "restored-partial" ||
|
|
412
|
+
action === "already-baseline"
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function normalizeStyler(styler: Pick<Theme, "bold" | "fg">): Styler {
|
|
417
|
+
return {
|
|
418
|
+
bold: (text) =>
|
|
419
|
+
typeof styler.bold === "function"
|
|
420
|
+
? styler.bold(text)
|
|
421
|
+
: IDENTITY_STYLER.bold(text),
|
|
422
|
+
fg: (color, text) =>
|
|
423
|
+
typeof styler.fg === "function"
|
|
424
|
+
? styler.fg(color, text)
|
|
425
|
+
: IDENTITY_STYLER.fg(color, text),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function sameModel(
|
|
430
|
+
left: { provider: string; id: string } | null,
|
|
431
|
+
right: { provider: string; id: string } | null,
|
|
432
|
+
): boolean {
|
|
433
|
+
return left?.provider === right?.provider && left?.id === right?.id;
|
|
434
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dirty-flag transitions for the active preset attachment.
|
|
3
|
+
*
|
|
4
|
+
* Owns flipping the in-memory dirty flag and refreshing the status badge;
|
|
5
|
+
* it does NOT decide whether drift exists, notify the user, or read the
|
|
6
|
+
* on-disk preset files. Status refresh uses a synthetic preset built from
|
|
7
|
+
* the active state's cached `declared` snapshot, so the helpers stay
|
|
8
|
+
* in-memory only and never reopen the preset JSON files.
|
|
9
|
+
*/
|
|
10
|
+
import type { ActivePresetState, LoadedPreset } from "../types.js";
|
|
11
|
+
import { updateStatus } from "../ui/status.js";
|
|
12
|
+
import { getActive, setActive } from "./active-state.js";
|
|
13
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
/** Minimal context surface needed to refresh status after dirty changes. */
|
|
16
|
+
type DirtyContext = Pick<ExtensionContext, "ui">;
|
|
17
|
+
|
|
18
|
+
/** Mark the active preset clean, preserving its restore discriminator. */
|
|
19
|
+
export async function markClean(ctx: DirtyContext): Promise<void> {
|
|
20
|
+
const active = getActive();
|
|
21
|
+
|
|
22
|
+
if (!active?.dirty) return;
|
|
23
|
+
|
|
24
|
+
setActive({ ...active, dirty: false });
|
|
25
|
+
refreshStatus(ctx, getActive());
|
|
26
|
+
|
|
27
|
+
await Promise.resolve();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Mark the active preset dirty, preserving its restore discriminator. */
|
|
31
|
+
export async function markDirty(ctx: DirtyContext): Promise<void> {
|
|
32
|
+
const active = getActive();
|
|
33
|
+
|
|
34
|
+
if (!active || active.dirty) return;
|
|
35
|
+
|
|
36
|
+
setActive({ ...active, dirty: true });
|
|
37
|
+
refreshStatus(ctx, getActive());
|
|
38
|
+
|
|
39
|
+
await Promise.resolve();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function refreshStatus(
|
|
43
|
+
ctx: DirtyContext,
|
|
44
|
+
active: ActivePresetState | undefined,
|
|
45
|
+
): void {
|
|
46
|
+
// Synthesize a `LoadedPreset` from the active state so `updateStatus`
|
|
47
|
+
// can render `Preset: <name>` without re-reading the preset JSON files.
|
|
48
|
+
// The badge only needs `name` + `scope`; everything else is filler.
|
|
49
|
+
const synthetic: LoadedPreset | undefined = active
|
|
50
|
+
? {
|
|
51
|
+
model: active.declared.model,
|
|
52
|
+
name: active.name,
|
|
53
|
+
provider: active.declared.provider,
|
|
54
|
+
scope: active.scope,
|
|
55
|
+
...(active.declared.thinkingLevel !== undefined
|
|
56
|
+
? { thinkingLevel: active.declared.thinkingLevel }
|
|
57
|
+
: {}),
|
|
58
|
+
...(active.declared.tools !== undefined
|
|
59
|
+
? { tools: [...active.declared.tools] }
|
|
60
|
+
: {}),
|
|
61
|
+
}
|
|
62
|
+
: undefined;
|
|
63
|
+
|
|
64
|
+
updateStatus(ctx, active, (name, scope) =>
|
|
65
|
+
synthetic && synthetic.name === name && synthetic.scope === scope
|
|
66
|
+
? synthetic
|
|
67
|
+
: undefined,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event-handler logic for active-preset drift tracking.
|
|
3
|
+
*
|
|
4
|
+
* Owns translating Pi model/thinking/turn events into dirty-state updates;
|
|
5
|
+
* it does NOT register handlers with Pi, render picker/status UI directly,
|
|
6
|
+
* or read the on-disk preset files. Drift detection compares against the
|
|
7
|
+
* `declared` snapshot cached on `ActivePresetState` at apply / restore time
|
|
8
|
+
* so per-turn handlers stay in-memory only.
|
|
9
|
+
*/
|
|
10
|
+
import { getActive } from "./active-state.js";
|
|
11
|
+
import { isSelfTriggeredModelSet } from "./apply.js";
|
|
12
|
+
import { markClean, markDirty } from "./dirty.js";
|
|
13
|
+
import { detectDriftReasons } from "./drift.js";
|
|
14
|
+
import type {
|
|
15
|
+
ExtensionAPI,
|
|
16
|
+
ExtensionContext,
|
|
17
|
+
} from "@mariozechner/pi-coding-agent";
|
|
18
|
+
|
|
19
|
+
/** Minimal model_select event surface needed for drift handling. */
|
|
20
|
+
interface ModelSelectLikeEvent {
|
|
21
|
+
model: { id: string; provider: string };
|
|
22
|
+
source: "cycle" | "restore" | "set";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Minimal context surface needed for drift comparison and status refresh. */
|
|
26
|
+
type DriftHandlerContext = Pick<
|
|
27
|
+
ExtensionContext,
|
|
28
|
+
"model" | "modelRegistry" | "ui"
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
/** Minimal Pi surface needed for full-state drift sync. */
|
|
32
|
+
type DriftHandlerPi = Pick<ExtensionAPI, "getActiveTools" | "getThinkingLevel">;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Handle `model_select` by re-evaluating drift against the cached snapshot.
|
|
36
|
+
*
|
|
37
|
+
* The model-match branch deliberately delegates to a full drift recheck
|
|
38
|
+
* (instead of unconditionally marking clean) so that re-selecting the
|
|
39
|
+
* preset's model while thinking or tools are still drifted does not produce
|
|
40
|
+
* a stale-clean badge until the next `turn_start` runs.
|
|
41
|
+
*/
|
|
42
|
+
export async function handleModelSelectDrift(
|
|
43
|
+
event: ModelSelectLikeEvent,
|
|
44
|
+
ctx: DriftHandlerContext,
|
|
45
|
+
pi: DriftHandlerPi,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
if (isSelfTriggeredModelSet()) return;
|
|
48
|
+
if (event.source === "restore") return;
|
|
49
|
+
|
|
50
|
+
await syncDirtyFromCurrentState(ctx, pi);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Recompute all drift reasons and update the dirty flag if needed. */
|
|
54
|
+
export async function syncDirtyFromCurrentState(
|
|
55
|
+
ctx: DriftHandlerContext,
|
|
56
|
+
pi: DriftHandlerPi,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const active = getActive();
|
|
59
|
+
|
|
60
|
+
if (!active) return;
|
|
61
|
+
|
|
62
|
+
const reasons = detectDriftReasons(active.declared, pi, ctx);
|
|
63
|
+
|
|
64
|
+
if (reasons.length === 0) {
|
|
65
|
+
if (active.dirty) await markClean(ctx);
|
|
66
|
+
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!active.dirty) await markDirty(ctx);
|
|
71
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drift-reason detection for active presets.
|
|
3
|
+
*
|
|
4
|
+
* Owns comparing current Pi model, thinking, and tools against a preset
|
|
5
|
+
* snapshot; it does NOT mutate active state, notify users, render UI, or
|
|
6
|
+
* read the on-disk preset files.
|
|
7
|
+
*/
|
|
8
|
+
import type { LoadedPreset, PresetDriftSnapshot } from "../types.js";
|
|
9
|
+
import { sameSet } from "./same-set.js";
|
|
10
|
+
import { effectiveThinkingLevel } from "./thinking.js";
|
|
11
|
+
import type {
|
|
12
|
+
ExtensionAPI,
|
|
13
|
+
ExtensionContext,
|
|
14
|
+
} from "@mariozechner/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
/** Minimal context surface needed for drift comparison. */
|
|
17
|
+
type DriftContext = Pick<ExtensionContext, "model" | "modelRegistry">;
|
|
18
|
+
|
|
19
|
+
/** Minimal Pi surface needed for drift comparison. */
|
|
20
|
+
type DriftPi = Pick<ExtensionAPI, "getActiveTools" | "getThinkingLevel">;
|
|
21
|
+
|
|
22
|
+
/** Return the preset dimensions whose current Pi values differ. */
|
|
23
|
+
export function detectDriftReasons(
|
|
24
|
+
declared: PresetDriftSnapshot,
|
|
25
|
+
pi: DriftPi,
|
|
26
|
+
ctx: DriftContext,
|
|
27
|
+
): string[] {
|
|
28
|
+
const reasons: string[] = [];
|
|
29
|
+
|
|
30
|
+
if (
|
|
31
|
+
ctx.model?.provider !== declared.provider ||
|
|
32
|
+
ctx.model.id !== declared.model
|
|
33
|
+
) {
|
|
34
|
+
reasons.push("model");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const model = ctx.modelRegistry.find(declared.provider, declared.model);
|
|
38
|
+
|
|
39
|
+
if (
|
|
40
|
+
pi.getThinkingLevel() !==
|
|
41
|
+
effectiveThinkingLevel({ thinkingLevel: declared.thinkingLevel }, model)
|
|
42
|
+
) {
|
|
43
|
+
reasons.push("thinking level");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (declared.tools && declared.tools.length > 0) {
|
|
47
|
+
if (!sameSet(pi.getActiveTools(), declared.tools)) reasons.push("tools");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return reasons;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build a drift snapshot from a freshly resolved preset.
|
|
55
|
+
*
|
|
56
|
+
* Stored on `ActivePresetState.declared` at apply / restore time so the
|
|
57
|
+
* per-turn comparison never reopens the preset JSON files. The snapshot is
|
|
58
|
+
* deliberately minimal \u2014 only the fields drift detection actually compares.
|
|
59
|
+
*/
|
|
60
|
+
export function snapshotPresetForDrift(
|
|
61
|
+
preset: Pick<LoadedPreset, "provider" | "model" | "thinkingLevel" | "tools">,
|
|
62
|
+
): PresetDriftSnapshot {
|
|
63
|
+
const snapshot: PresetDriftSnapshot = {
|
|
64
|
+
model: preset.model,
|
|
65
|
+
provider: preset.provider,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (preset.thinkingLevel !== undefined) {
|
|
69
|
+
snapshot.thinkingLevel = preset.thinkingLevel;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (preset.tools !== undefined) {
|
|
73
|
+
snapshot.tools = [...preset.tools];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return snapshot;
|
|
77
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Set-equality helpers shared across activation modules.
|
|
3
|
+
*
|
|
4
|
+
* Owns the tiny comparison primitives used by clear-decision and
|
|
5
|
+
* state-match logic; it does NOT carry any activation state or side
|
|
6
|
+
* effects. Lives here (rather than inline in each caller) because the
|
|
7
|
+
* `sameSet` literal was copy-pasted verbatim across two modules — one
|
|
8
|
+
* source of truth is easier to reason about when the comparison rules
|
|
9
|
+
* change (e.g. if we ever need to ignore case or treat `undefined` as
|
|
10
|
+
* empty).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compare two string arrays as unordered sets.
|
|
15
|
+
*
|
|
16
|
+
* Returns `true` when both sides contain exactly the same set of
|
|
17
|
+
* distinct values, ignoring order. Duplicate entries within either
|
|
18
|
+
* array are tolerated because the length-first guard rejects any case
|
|
19
|
+
* where duplicates would matter for set-equality purposes: if both
|
|
20
|
+
* sides have the same length and every element of `left` appears in
|
|
21
|
+
* `right`, the sets are equal.
|
|
22
|
+
*/
|
|
23
|
+
export function sameSet(
|
|
24
|
+
left: readonly string[],
|
|
25
|
+
right: readonly string[],
|
|
26
|
+
): boolean {
|
|
27
|
+
if (left.length !== right.length) return false;
|
|
28
|
+
|
|
29
|
+
const rightSet = new Set(right);
|
|
30
|
+
|
|
31
|
+
return left.every((value) => rightSet.has(value));
|
|
32
|
+
}
|