@sherif-fanous/pi-presets-plus 0.1.0 → 0.1.2

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 CHANGED
@@ -2,8 +2,23 @@
2
2
 
3
3
  This changelog follows [Common Changelog](https://common-changelog.org/).
4
4
 
5
+ ## [0.1.2] - 2026-05-09
6
+
7
+ ### Fixed
8
+
9
+ - Pressing `c` (clear) inside the preset picker with no preset active no longer opens an empty confirm-then-nothing dialog. The picker now shows an info-dialog stating "No preset is active." and returns to the picker.
10
+ - The session-start notification for a preset that shadows a Pi built-in now uses warning severity to match the visual treatment of preset-vs-preset hotkey conflicts. Both collision-style notifications render consistently.
11
+
12
+ ## [0.1.1] - 2026-05-09
13
+
14
+ ### Changed
15
+
16
+ - Refactored the extension's internal architecture so the active-preset state, the runtime hotkey bindings, and the clear-summary renderer each live in a single dedicated module. No user-visible behavior change; the picker, editor, `/presets` subcommands, `--preset` flag, hotkeys, drift detection, and session restore all behave identically.
17
+
5
18
  ## [0.1.0] - 2026-05-09
6
19
 
7
20
  _Initial release._
8
21
 
22
+ [0.1.2]: https://github.com/sherif-fanous/pi-presets-plus/releases/tag/v0.1.2
23
+ [0.1.1]: https://github.com/sherif-fanous/pi-presets-plus/releases/tag/v0.1.1
9
24
  [0.1.0]: https://github.com/sherif-fanous/pi-presets-plus/releases/tag/v0.1.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sherif-fanous/pi-presets-plus",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Pi extension: model + thinking + tools + system-prompt presets, with a UI on top.",
5
5
  "keywords": [
6
6
  "pi",
@@ -7,12 +7,10 @@
7
7
  * picker UI.
8
8
  */
9
9
  import { ACTIVATED_MESSAGE_TYPE } from "../messages.js";
10
+ import { samePresetIdentity } from "../preset-identity.js";
10
11
  import type { LoadedPreset } from "../types.js";
11
- import { updateStatus } from "../ui/status.js";
12
- import { getActive, setActive } from "./active-state.js";
13
12
  import { captureBaseline } from "./baseline.js";
14
- import { markClean } from "./dirty.js";
15
- import { snapshotPresetForDrift } from "./drift.js";
13
+ import type { ActivePresetSession } from "./session.js";
16
14
  import { stateMatches } from "./state-matches.js";
17
15
  import { effectiveThinkingLevel } from "./thinking.js";
18
16
  import type {
@@ -20,14 +18,6 @@ import type {
20
18
  ExtensionContext,
21
19
  } from "@mariozechner/pi-coding-agent";
22
20
 
23
- /**
24
- * Counter (not boolean) so nested `withSelfTriggeredModelSet` calls cannot
25
- * silently drop the guard when the inner call's `finally` runs while the
26
- * outer call is still in flight. Today there is only one caller, but the
27
- * counter shape costs nothing and removes a footgun for future callers.
28
- */
29
- let selfTriggeredModelSetDepth = 0;
30
-
31
21
  /**
32
22
  * In-memory result from applying a preset.
33
23
  *
@@ -55,6 +45,7 @@ export async function apply(
55
45
  preset: LoadedPreset,
56
46
  ctx: ExtensionContext,
57
47
  pi: ExtensionAPI,
48
+ session: ActivePresetSession,
58
49
  ): Promise<ApplyResult> {
59
50
  if (preset.unavailable) {
60
51
  const kind = preset.unavailable;
@@ -62,15 +53,15 @@ export async function apply(
62
53
  return { ok: false, kind, reason: failureReason(kind, preset) };
63
54
  }
64
55
 
65
- const current = getActive();
56
+ const current = session.current();
66
57
 
67
58
  if (
68
- current?.name === preset.name &&
69
- current.scope === preset.scope &&
59
+ current &&
60
+ samePresetIdentity(current, preset) &&
70
61
  current.restore.kind === "baseline" &&
71
62
  stateMatches(preset, pi, ctx)
72
63
  ) {
73
- if (current.dirty) await markClean(ctx);
64
+ if (current.dirty) session.markClean(ctx);
74
65
 
75
66
  return { ok: true };
76
67
  }
@@ -92,7 +83,7 @@ export async function apply(
92
83
  const previousAppliedTools = previousBaseline?.lastApplied.tools;
93
84
  const previousOwnedTools = previousBaseline?.owned.tools ?? false;
94
85
 
95
- if (!(await setModelGuarded(pi, model))) {
86
+ if (!(await setModelGuarded(pi, model, session))) {
96
87
  return {
97
88
  ok: false,
98
89
  kind: "key-revoked",
@@ -133,28 +124,27 @@ export async function apply(
133
124
  ownedTools = true;
134
125
  }
135
126
 
136
- setActive({
137
- declared: snapshotPresetForDrift({ ...preset, tools: appliedTools }),
138
- dirty: false,
139
- name: preset.name,
140
- restore: {
127
+ // session.start commits the new active state (and refreshes the status
128
+ // badge as part of that commit) BEFORE the customType message is
129
+ // emitted, so observers seeing the "Preset … applied" message can
130
+ // already query the updated state. The reverse order — message first,
131
+ // status second — would briefly publish an event whose corresponding
132
+ // state is not yet visible.
133
+ session.start(
134
+ {
141
135
  applyCount,
142
136
  baseline,
143
- kind: "baseline",
144
137
  lastApplied: {
145
138
  ...(appliedTools !== undefined ? { tools: appliedTools } : {}),
146
139
  model: { id: preset.model, provider: preset.provider },
147
140
  thinkingLevel: effective,
148
141
  },
149
142
  owned: { model: true, thinkingLevel: true, tools: ownedTools },
143
+ preset,
150
144
  },
151
- scope: preset.scope,
152
- });
153
-
154
- pi.appendEntry("presets-plus:active", {
155
- name: preset.name,
156
- scope: preset.scope,
157
- });
145
+ ctx,
146
+ pi,
147
+ );
158
148
 
159
149
  pi.sendMessage({
160
150
  customType: ACTIVATED_MESSAGE_TYPE,
@@ -167,36 +157,9 @@ export async function apply(
167
157
  },
168
158
  });
169
159
 
170
- updateStatus(ctx, getActive(), (name, scope) =>
171
- name === preset.name && scope === preset.scope ? preset : undefined,
172
- );
173
-
174
160
  return { ok: true };
175
161
  }
176
162
 
177
- export function isSelfTriggeredModelSet(): boolean {
178
- return selfTriggeredModelSetDepth > 0;
179
- }
180
-
181
- /**
182
- * Run `fn` with the self-call counter raised so a `model_select` event
183
- * triggered by `pi.setModel` inside `fn` is recognized as our own write
184
- * and ignored by the drift-detection handler. The counter shape (vs. a
185
- * boolean) keeps nested or concurrent calls from accidentally lowering the
186
- * guard while an outer call is still in flight.
187
- */
188
- export async function withSelfTriggeredModelSet<T>(
189
- fn: () => Promise<T>,
190
- ): Promise<T> {
191
- selfTriggeredModelSetDepth++;
192
-
193
- try {
194
- return await fn();
195
- } finally {
196
- selfTriggeredModelSetDepth--;
197
- }
198
- }
199
-
200
163
  function failureReason(
201
164
  kind: Exclude<ApplyResult, { ok: true }>["kind"],
202
165
  preset: Pick<LoadedPreset, "model" | "name" | "provider">,
@@ -231,6 +194,7 @@ function filterValidTools(
231
194
  async function setModelGuarded(
232
195
  pi: Pick<ExtensionAPI, "setModel">,
233
196
  model: NonNullable<ReturnType<ExtensionContext["modelRegistry"]["find"]>>,
197
+ session: ActivePresetSession,
234
198
  ): Promise<boolean> {
235
- return withSelfTriggeredModelSet(() => pi.setModel(model));
199
+ return session.withSelfTriggeredModelSet(() => pi.setModel(model));
236
200
  }
@@ -2,24 +2,20 @@
2
2
  * Active-preset clear flow.
3
3
  *
4
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.
5
+ * protection) and deciding clear outcomes; it does NOT own apply, picker UI,
6
+ * or pure clear-summary rendering.
7
7
  */
8
8
  import type { ActivePresetState, ThinkingLevel } from "../types.js";
9
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";
10
+ formatModel,
11
+ formatTools,
12
+ renderClearSummary,
13
+ } from "../ui/clear-summary.js";
18
14
  import { sameSet } from "./same-set.js";
15
+ import type { ActivePresetSession } from "./session.js";
19
16
  import type {
20
17
  ExtensionAPI,
21
18
  ExtensionCommandContext,
22
- Theme,
23
19
  } from "@mariozechner/pi-coding-agent";
24
20
 
25
21
  export interface ClearDecision {
@@ -45,6 +41,11 @@ export interface ClearPart {
45
41
  readonly value: string;
46
42
  }
47
43
 
44
+ export interface ClearResult {
45
+ readonly name: string;
46
+ readonly parts: readonly ClearPart[];
47
+ }
48
+
48
49
  export interface ClearSnapshot {
49
50
  readonly active: ActivePresetState;
50
51
  readonly allTools: readonly string[];
@@ -71,73 +72,12 @@ export type ClearAction =
71
72
 
72
73
  export type ClearField = "model" | "thinking" | "tools";
73
74
 
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
75
  export async function clear(
137
76
  ctx: ExtensionCommandContext,
138
77
  pi: ExtensionAPI,
78
+ session: ActivePresetSession,
139
79
  ): Promise<void> {
140
- const result = await clearReturning(ctx, pi);
80
+ const result = await clearReturning(ctx, pi, session);
141
81
 
142
82
  ctx.ui.notify(
143
83
  result
@@ -150,8 +90,9 @@ export async function clear(
150
90
  export async function clearReturning(
151
91
  ctx: ExtensionCommandContext,
152
92
  pi: ExtensionAPI,
93
+ session: ActivePresetSession,
153
94
  ): Promise<ClearResult | undefined> {
154
- const active = getActive();
95
+ const active = session.current();
155
96
 
156
97
  if (!active) return undefined;
157
98
 
@@ -165,11 +106,9 @@ export async function clearReturning(
165
106
  currentThinking: pi.getThinkingLevel(),
166
107
  currentTools: pi.getActiveTools(),
167
108
  });
168
- const finalParts = await executeClear(decision, ctx, pi);
109
+ const finalParts = await executeClear(decision, ctx, pi, session);
169
110
 
170
- clearActive();
171
- pi.appendEntry("presets-plus:active", { name: null });
172
- updateStatus(ctx, getActive(), () => undefined);
111
+ session.clear(ctx, pi);
173
112
 
174
113
  return { name: active.name, parts: finalParts };
175
114
  }
@@ -293,32 +232,11 @@ export function decideClear(snapshot: ClearSnapshot): ClearDecision {
293
232
  return { parts, writes };
294
233
  }
295
234
 
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
235
  async function executeClear(
319
236
  decision: ClearDecision,
320
237
  ctx: Pick<ExtensionCommandContext, "modelRegistry">,
321
238
  pi: Pick<ExtensionAPI, "setActiveTools" | "setModel" | "setThinkingLevel">,
239
+ session: ActivePresetSession,
322
240
  ): Promise<ClearPart[]> {
323
241
  const parts = decision.parts.map((part) => ({ ...part }));
324
242
 
@@ -326,7 +244,7 @@ async function executeClear(
326
244
  const target = decision.writes.model;
327
245
  const model = ctx.modelRegistry.find(target.provider, target.id);
328
246
  const ok = model
329
- ? await withSelfTriggeredModelSet(() => pi.setModel(model))
247
+ ? await session.withSelfTriggeredModelSet(() => pi.setModel(model))
330
248
  : false;
331
249
 
332
250
  if (!ok) {
@@ -353,79 +271,6 @@ async function executeClear(
353
271
  return parts;
354
272
  }
355
273
 
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
274
  function sameModel(
430
275
  left: { provider: string; id: string } | null,
431
276
  right: { provider: string; id: string } | null,
@@ -7,10 +7,8 @@
7
7
  * `declared` snapshot cached on `ActivePresetState` at apply / restore time
8
8
  * so per-turn handlers stay in-memory only.
9
9
  */
10
- import { getActive } from "./active-state.js";
11
- import { isSelfTriggeredModelSet } from "./apply.js";
12
- import { markClean, markDirty } from "./dirty.js";
13
10
  import { detectDriftReasons } from "./drift.js";
11
+ import type { ActivePresetSession } from "./session.js";
14
12
  import type {
15
13
  ExtensionAPI,
16
14
  ExtensionContext,
@@ -43,29 +41,33 @@ export async function handleModelSelectDrift(
43
41
  event: ModelSelectLikeEvent,
44
42
  ctx: DriftHandlerContext,
45
43
  pi: DriftHandlerPi,
44
+ session: ActivePresetSession,
46
45
  ): Promise<void> {
47
- if (isSelfTriggeredModelSet()) return;
46
+ if (session.isSelfTriggered()) return;
48
47
  if (event.source === "restore") return;
49
48
 
50
- await syncDirtyFromCurrentState(ctx, pi);
49
+ await syncDirtyFromCurrentState(ctx, pi, session);
51
50
  }
52
51
 
53
52
  /** Recompute all drift reasons and update the dirty flag if needed. */
54
53
  export async function syncDirtyFromCurrentState(
55
54
  ctx: DriftHandlerContext,
56
55
  pi: DriftHandlerPi,
56
+ session: ActivePresetSession,
57
57
  ): Promise<void> {
58
- const active = getActive();
58
+ const active = session.current();
59
59
 
60
60
  if (!active) return;
61
61
 
62
62
  const reasons = detectDriftReasons(active.declared, pi, ctx);
63
63
 
64
64
  if (reasons.length === 0) {
65
- if (active.dirty) await markClean(ctx);
65
+ if (active.dirty) session.markClean(ctx);
66
66
 
67
67
  return;
68
68
  }
69
69
 
70
- if (!active.dirty) await markDirty(ctx);
70
+ if (!active.dirty) session.markDirty(ctx);
71
+
72
+ await Promise.resolve();
71
73
  }