@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/package.json +74 -0
  5. package/src/activation/active-state.ts +25 -0
  6. package/src/activation/apply.ts +236 -0
  7. package/src/activation/baseline.ts +32 -0
  8. package/src/activation/clear.ts +434 -0
  9. package/src/activation/dirty.ts +69 -0
  10. package/src/activation/drift-handlers.ts +71 -0
  11. package/src/activation/drift.ts +77 -0
  12. package/src/activation/same-set.ts +32 -0
  13. package/src/activation/state-matches.ts +29 -0
  14. package/src/activation/thinking.ts +54 -0
  15. package/src/commands/presets/clear.ts +18 -0
  16. package/src/commands/presets/index.ts +9 -0
  17. package/src/commands/presets/notify.ts +22 -0
  18. package/src/commands/presets/reload.ts +28 -0
  19. package/src/commands/presets/router.ts +139 -0
  20. package/src/commands/presets/status.ts +262 -0
  21. package/src/flag.ts +88 -0
  22. package/src/hotkey-conflicts.ts +136 -0
  23. package/src/hotkey-reload-baseline.ts +112 -0
  24. package/src/hotkeys.ts +104 -0
  25. package/src/index.ts +171 -0
  26. package/src/messages.ts +34 -0
  27. package/src/store/api.ts +262 -0
  28. package/src/store/load.ts +175 -0
  29. package/src/store/merge.ts +69 -0
  30. package/src/store/paths.ts +38 -0
  31. package/src/store/save.ts +75 -0
  32. package/src/store/validate.ts +195 -0
  33. package/src/types.ts +169 -0
  34. package/src/ui/confirm.ts +126 -0
  35. package/src/ui/editor.ts +1617 -0
  36. package/src/ui/filter.ts +79 -0
  37. package/src/ui/frame.ts +109 -0
  38. package/src/ui/hotkey-input.ts +242 -0
  39. package/src/ui/info-dialog.ts +118 -0
  40. package/src/ui/labels.ts +51 -0
  41. package/src/ui/picker-state.ts +151 -0
  42. package/src/ui/picker.ts +982 -0
  43. package/src/ui/reload-prompt.ts +59 -0
  44. package/src/ui/status.ts +55 -0
  45. package/src/ui/widgets.ts +274 -0
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Validation primitives for the preset storage layer.
3
+ *
4
+ * Owns shape validation of individual presets, duplicate-name detection
5
+ * within a list, and runtime availability checks against pi's model
6
+ * registry. None of these helpers throw or perform file-system I/O.
7
+ */
8
+ import type { Preset, ThinkingLevel } from "../types.js";
9
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
10
+
11
+ /** Result of a single-preset shape check. */
12
+ interface ValidationResult {
13
+ ok: boolean;
14
+ /** Human-readable reason the preset failed validation; absent on success. */
15
+ reason?: string;
16
+ }
17
+
18
+ /** Allowed values for `Preset.thinkingLevel`. */
19
+ const THINKING_LEVELS: readonly ThinkingLevel[] = [
20
+ "off",
21
+ "minimal",
22
+ "low",
23
+ "medium",
24
+ "high",
25
+ "xhigh",
26
+ ] as const;
27
+
28
+ /**
29
+ * Compute the availability of a preset against the live model registry.
30
+ *
31
+ * Returns:
32
+ * - `"no-model"` the preset's `provider`/`model` is not registered
33
+ * - `"no-key"` the model is registered but its provider has no API key
34
+ * - `undefined` the preset is fully available
35
+ *
36
+ * Does not perform any network I/O: the API key check uses
37
+ * `hasConfiguredAuth` (synchronous, fast) rather than refreshing OAuth
38
+ * tokens. Activation-time code paths (later change) re-check with the
39
+ * async resolver.
40
+ */
41
+ export function computeAvailability(
42
+ preset: Pick<Preset, "provider" | "model">,
43
+ ctx: Pick<ExtensionContext, "modelRegistry">,
44
+ ): "no-model" | "no-key" | undefined {
45
+ const model = ctx.modelRegistry.find(preset.provider, preset.model);
46
+
47
+ if (!model) return "no-model";
48
+ if (!ctx.modelRegistry.hasConfiguredAuth(model)) return "no-key";
49
+
50
+ return undefined;
51
+ }
52
+
53
+ /** Return whether activation will clamp a preset's thinking level to off. */
54
+ export function computeClampWarning(
55
+ preset: Pick<Preset, "provider" | "model" | "thinkingLevel">,
56
+ ctx: Pick<ExtensionContext, "modelRegistry">,
57
+ ): boolean {
58
+ if (!preset.thinkingLevel || preset.thinkingLevel === "off") return false;
59
+
60
+ const model = ctx.modelRegistry.find(preset.provider, preset.model);
61
+
62
+ if (!model) return false;
63
+
64
+ return model.reasoning === false;
65
+ }
66
+
67
+ /**
68
+ * Find duplicate `name` entries in a preset array, preserving the index of
69
+ * each duplicate (i.e. the second and subsequent occurrences) so the
70
+ * loader can skip-and-warn naming the offenders.
71
+ *
72
+ * Pure: does not mutate the input array.
73
+ */
74
+ export function findDuplicatePresetNames(
75
+ presets: readonly Preset[],
76
+ ): { name: string; index: number }[] {
77
+ const seenPresetNames = new Set<string>();
78
+ const duplicatePresetNames: { name: string; index: number }[] = [];
79
+
80
+ for (let i = 0; i < presets.length; i++) {
81
+ const preset = presets[i];
82
+
83
+ if (!preset) continue;
84
+
85
+ const name = preset.name;
86
+
87
+ if (seenPresetNames.has(name)) {
88
+ duplicatePresetNames.push({ name, index: i });
89
+ } else {
90
+ seenPresetNames.add(name);
91
+ }
92
+ }
93
+
94
+ return duplicatePresetNames;
95
+ }
96
+
97
+ /**
98
+ * Validate the shape of a single preset.
99
+ *
100
+ * Required fields (per the storage spec):
101
+ * - `name` non-empty string
102
+ * - `provider` non-empty string
103
+ * - `model` non-empty string
104
+ *
105
+ * Optional fields are checked when present:
106
+ * - `thinkingLevel` must be in the `ThinkingLevel` enum
107
+ * - `tools` must be an array of strings
108
+ * - `instructions` must be a string
109
+ * - `hotkey` must be a string
110
+ * - `order` must be a finite number
111
+ *
112
+ * Unknown fields are accepted (forward-compat); the loader's serializer
113
+ * only round-trips the typed shape but extra fields are not flagged.
114
+ */
115
+ export function validatePresetShape(
116
+ candidatePreset: unknown,
117
+ ): ValidationResult {
118
+ if (
119
+ typeof candidatePreset !== "object" ||
120
+ candidatePreset === null ||
121
+ Array.isArray(candidatePreset)
122
+ ) {
123
+ return { ok: false, reason: "Preset is not an object." };
124
+ }
125
+
126
+ const obj = candidatePreset as Record<string, unknown>;
127
+ const requireString = (
128
+ field: "name" | "provider" | "model",
129
+ ): ValidationResult | undefined => {
130
+ const value = obj[field];
131
+
132
+ if (typeof value !== "string" || value.length === 0) {
133
+ return {
134
+ ok: false,
135
+ reason: `Missing or empty required field "${field}".`,
136
+ };
137
+ }
138
+
139
+ return undefined;
140
+ };
141
+ const nameError = requireString("name");
142
+
143
+ if (nameError) return nameError;
144
+
145
+ const providerError = requireString("provider");
146
+
147
+ if (providerError) return providerError;
148
+
149
+ const modelError = requireString("model");
150
+
151
+ if (modelError) return modelError;
152
+
153
+ if (obj.thinkingLevel !== undefined) {
154
+ if (
155
+ typeof obj.thinkingLevel !== "string" ||
156
+ !THINKING_LEVELS.includes(obj.thinkingLevel as ThinkingLevel)
157
+ ) {
158
+ const label =
159
+ typeof obj.thinkingLevel === "string"
160
+ ? JSON.stringify(obj.thinkingLevel)
161
+ : typeof obj.thinkingLevel;
162
+
163
+ return {
164
+ ok: false,
165
+ reason: `Invalid thinkingLevel ${label} (expected one of ${THINKING_LEVELS.join(", ")}).`,
166
+ };
167
+ }
168
+ }
169
+
170
+ if (obj.tools !== undefined) {
171
+ if (
172
+ !Array.isArray(obj.tools) ||
173
+ obj.tools.some((tool) => typeof tool !== "string")
174
+ ) {
175
+ return { ok: false, reason: `"tools" must be an array of strings.` };
176
+ }
177
+ }
178
+
179
+ if (obj.instructions !== undefined && typeof obj.instructions !== "string") {
180
+ return { ok: false, reason: `"instructions" must be a string.` };
181
+ }
182
+
183
+ if (obj.hotkey !== undefined && typeof obj.hotkey !== "string") {
184
+ return { ok: false, reason: `"hotkey" must be a string.` };
185
+ }
186
+
187
+ if (
188
+ obj.order !== undefined &&
189
+ (typeof obj.order !== "number" || !Number.isFinite(obj.order))
190
+ ) {
191
+ return { ok: false, reason: `"order" must be a finite number.` };
192
+ }
193
+
194
+ return { ok: true };
195
+ }
package/src/types.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Shared type definitions for pi-presets-plus.
3
+ *
4
+ * Owns the persistent preset shapes (`Preset`, `PresetsFile`), scope and
5
+ * loader output types (`PresetScope`, `LoadedPreset`), the activation
6
+ * state shape, and a local `ThinkingLevel` that extends `pi-ai`'s level
7
+ * set with the explicit `"off"` value used by pi.
8
+ */
9
+
10
+ /**
11
+ * A preset enriched with merge/availability metadata.
12
+ *
13
+ * Returned by the storage merge step (`mergeScopes`). `shadowed` and
14
+ * `unavailable` are computed at load time and may change across reloads;
15
+ * callers must not assume they survive a `ctx.reload()`.
16
+ */
17
+ export interface LoadedPreset extends Preset {
18
+ /** Origin file's scope; assigned by the loader. */
19
+ scope: PresetScope;
20
+ /**
21
+ * `true` for a global preset whose name is also defined in the project
22
+ * file (the project entry wins at activation time).
23
+ */
24
+ shadowed?: boolean;
25
+ /**
26
+ * Reason the preset cannot be activated, computed at load time:
27
+ * - `"no-model"` — model id not registered for the named provider
28
+ * - `"no-key"` — model is registered but its provider has no API key
29
+ *
30
+ * Undefined when the preset is fully available.
31
+ */
32
+ unavailable?: "no-key" | "no-model";
33
+ /**
34
+ * True when the preset requests extended thinking for a model that will
35
+ * clamp it to off at activation time. Computed in memory; never persisted.
36
+ */
37
+ clampWarning?: true;
38
+ /** True when another preset claimed this preset's hotkey first. */
39
+ hotkeyConflict?: true | undefined;
40
+ /**
41
+ * True when the parsed hotkey matches a Pi built-in keybinding. Computed by
42
+ * `annotateAndAnalyzeHotkeys` alongside `hotkeyConflict` at load time so
43
+ * consumers can surface the derived shadowing state.
44
+ */
45
+ hotkeyShadowsBuiltin?: true | undefined;
46
+ }
47
+
48
+ /**
49
+ * A preset definition as it appears in either scope's JSON file.
50
+ *
51
+ * Required fields: `name`, `provider`, `model`. All other fields are
52
+ * optional and accepted by the loader unchanged; behavior that consumes
53
+ * them (instructions injection, hotkey binding, ordering) lands in later
54
+ * changes. Storage validates the shape and round-trips unknown-but-typed
55
+ * fields verbatim.
56
+ */
57
+ export interface Preset {
58
+ /** Unique within a single file; merge-time shadowing is by name. */
59
+ name: string;
60
+ /** Provider id (e.g. `"anthropic"`, `"openai"`). */
61
+ provider: string;
62
+ /** Model id within `provider` (e.g. `"claude-opus-4.5"`). */
63
+ model: string;
64
+ /** Reasoning level. Defaults to `"off"` at apply time (later change). */
65
+ thinkingLevel?: ThinkingLevel;
66
+ /** Active tools at apply time. Omit / empty = session tools pass through unchanged. */
67
+ tools?: string[];
68
+ /** Free-form text appended to the system prompt at apply time. */
69
+ instructions?: string;
70
+ /** Hotkey id; honored by a later change. */
71
+ hotkey?: string;
72
+ /** User-controlled cycle order; default = file order. */
73
+ order?: number;
74
+ }
75
+
76
+ /**
77
+ * In-memory snapshot of the preset fields drift detection compares against.
78
+ *
79
+ * Cached on `ActivePresetState` at apply / restore time so per-turn drift
80
+ * detection never has to re-read the on-disk preset files. Refreshed on
81
+ * apply, on session restore, and on `/presets reload` (via re-apply) — never
82
+ * on `turn_start` or `model_select`.
83
+ */
84
+ export interface PresetDriftSnapshot {
85
+ provider: string;
86
+ model: string;
87
+ thinkingLevel?: ThinkingLevel;
88
+ tools?: readonly string[];
89
+ }
90
+
91
+ /** Baseline Pi state captured before a preset overlay starts. */
92
+ export interface PresetOverlayBaseline {
93
+ model: { provider: string; id: string } | null;
94
+ thinkingLevel: ThinkingLevel;
95
+ tools: string[];
96
+ }
97
+
98
+ /**
99
+ * On-disk JSON shape for a single preset file (either scope).
100
+ *
101
+ * `version: 1` is the current schema version. Files declaring a different
102
+ * version are treated as empty + warned by the loader (and never rewritten),
103
+ * leaving room for forward-compatible schema evolution.
104
+ */
105
+ export interface PresetsFile {
106
+ version: 1;
107
+ presets: Preset[];
108
+ }
109
+
110
+ /** In-memory active-preset state for change `add-preset-activation`. */
111
+ export type ActivePresetState =
112
+ | {
113
+ name: string;
114
+ scope: PresetScope;
115
+ restore: {
116
+ kind: "baseline";
117
+ baseline: PresetOverlayBaseline;
118
+ lastApplied: LastAppliedPresetEffects;
119
+ owned: PresetOverlayOwnership;
120
+ applyCount: number;
121
+ };
122
+ dirty: boolean;
123
+ declared: PresetDriftSnapshot;
124
+ }
125
+ | {
126
+ name: string;
127
+ scope: PresetScope;
128
+ restore: { kind: "unknown" };
129
+ dirty: boolean;
130
+ declared: PresetDriftSnapshot;
131
+ };
132
+
133
+ /**
134
+ * Origin scope for a loaded preset.
135
+ *
136
+ * - `"user"` — the global file under `<agent-dir>/presets-plus/presets.json`
137
+ * - `"project"` — the per-cwd file under `<cwd>/.pi/presets-plus/presets.json`
138
+ */
139
+ export type PresetScope = "user" | "project";
140
+
141
+ /**
142
+ * Reasoning level recorded on a preset.
143
+ *
144
+ * Mirrors pi-coding-agent's `getThinkingLevel()` / `setThinkingLevel()` API,
145
+ * which extends `pi-ai`'s `ThinkingLevel` with the explicit `"off"` value.
146
+ * Storage accepts the literal set verbatim; per-model clamping happens at
147
+ * activation time in a later change.
148
+ */
149
+ export type ThinkingLevel =
150
+ | "off"
151
+ | "minimal"
152
+ | "low"
153
+ | "medium"
154
+ | "high"
155
+ | "xhigh";
156
+
157
+ /** Last values written by presets-plus inside the active overlay. */
158
+ interface LastAppliedPresetEffects {
159
+ model: { provider: string; id: string };
160
+ thinkingLevel: ThinkingLevel;
161
+ tools?: string[];
162
+ }
163
+
164
+ /** Tracks which Pi channels are owned by the active preset overlay. */
165
+ interface PresetOverlayOwnership {
166
+ model: true;
167
+ thinkingLevel: true;
168
+ tools: boolean;
169
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Small custom confirmation overlay shared by preset TUI surfaces.
3
+ *
4
+ * Owns yes/no keyboard handling for extension-local confirmations; it does
5
+ * NOT own the action being confirmed or any persistence side effects.
6
+ */
7
+ import { centerText, renderDialogFrame, wrapBody } from "./frame.js";
8
+ import type {
9
+ ExtensionCommandContext,
10
+ Theme,
11
+ } from "@mariozechner/pi-coding-agent";
12
+ import {
13
+ Key,
14
+ matchesKey,
15
+ type Component,
16
+ type Focusable,
17
+ } from "@mariozechner/pi-tui";
18
+
19
+ class ConfirmComponent implements Component, Focusable {
20
+ private selected: "no" | "yes" = "no";
21
+ private resolved = false;
22
+ private _focused = false;
23
+
24
+ constructor(
25
+ private readonly title: string,
26
+ private readonly message: string,
27
+ private readonly theme: Theme,
28
+ private readonly done: (result: boolean) => void,
29
+ ) {}
30
+
31
+ get focused(): boolean {
32
+ return this._focused;
33
+ }
34
+
35
+ set focused(value: boolean) {
36
+ this._focused = value;
37
+ }
38
+
39
+ handleInput(input: string): void {
40
+ if (matchesKey(input, Key.escape)) {
41
+ this.finish(false);
42
+
43
+ return;
44
+ }
45
+
46
+ if (matchesKey(input, Key.left) || matchesKey(input, Key.right)) {
47
+ this.selected = this.selected === "yes" ? "no" : "yes";
48
+
49
+ return;
50
+ }
51
+
52
+ if (input.toLowerCase() === "y") {
53
+ this.finish(true);
54
+
55
+ return;
56
+ }
57
+
58
+ if (input.toLowerCase() === "n") {
59
+ this.finish(false);
60
+
61
+ return;
62
+ }
63
+
64
+ if (matchesKey(input, Key.enter) || input === " ") {
65
+ this.finish(this.selected === "yes");
66
+ }
67
+ }
68
+
69
+ invalidate(): void {
70
+ // No cached layout or external data to invalidate.
71
+ }
72
+
73
+ render(width: number): string[] {
74
+ const frameWidth = Math.max(2, width);
75
+ const bodyWidth = Math.max(1, frameWidth - 2);
76
+ const messageLines = wrapBody(this.message, bodyWidth - 4);
77
+ const buttons = [
78
+ this.renderButton("yes", "Yes"),
79
+ this.renderButton("no", "No"),
80
+ ].join(" ");
81
+
82
+ return renderDialogFrame({
83
+ bodyLines: [
84
+ ...messageLines.map((line) => ` ${line}`),
85
+ "",
86
+ centerText(buttons, bodyWidth),
87
+ ],
88
+ footer: this.theme.fg("dim", " ←/→ choose · Enter confirm · Esc cancel "),
89
+ title: this.theme.fg("accent", this.theme.bold(this.title)),
90
+ width: frameWidth,
91
+ });
92
+ }
93
+
94
+ private finish(result: boolean): void {
95
+ if (this.resolved) return;
96
+ this.resolved = true;
97
+ this.done(result);
98
+ }
99
+
100
+ private renderButton(value: "no" | "yes", label: string): string {
101
+ const text = this.selected === value ? `● ${label}` : `○ ${label}`;
102
+
103
+ return this.selected === value ? this.theme.fg("accent", text) : text;
104
+ }
105
+ }
106
+
107
+ export async function openConfirm(
108
+ ctx: Pick<ExtensionCommandContext, "ui">,
109
+ title: string,
110
+ message: string,
111
+ ): Promise<boolean> {
112
+ return ctx.ui.custom<boolean>(
113
+ (_tui, theme, _keybindings, done) =>
114
+ new ConfirmComponent(title, message, theme, done),
115
+ {
116
+ overlay: true,
117
+ overlayOptions: {
118
+ anchor: "center",
119
+ margin: 2,
120
+ maxHeight: "50%",
121
+ minWidth: 48,
122
+ width: "50%",
123
+ },
124
+ },
125
+ );
126
+ }