@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,59 @@
1
+ /**
2
+ * Reload confirmation helper shared by commit-time preset mutations.
3
+ *
4
+ * Owns the user-facing reload prompt and guarded `ctx.reload()` invocation;
5
+ * it does NOT own deciding whether a particular preset mutation needs a
6
+ * reload.
7
+ */
8
+ import { openConfirm } from "./confirm.js";
9
+ import { RELOAD_PROMPT_TITLE } from "./labels.js";
10
+ import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
11
+
12
+ const RELOAD_PROMPT_BODY =
13
+ "Hotkey changes take effect after a reload. Reload now?";
14
+
15
+ interface ReloadContext {
16
+ readonly reload?: () => Promise<void>;
17
+ readonly ui: ExtensionCommandContext["ui"];
18
+ }
19
+
20
+ /** Ask whether Pi should reload now, returning false when reload is unavailable. */
21
+ export async function confirmReload(ctx: ReloadContext): Promise<boolean> {
22
+ if (typeof ctx.reload !== "function") return false;
23
+
24
+ return openConfirm(ctx, RELOAD_PROMPT_TITLE, RELOAD_PROMPT_BODY);
25
+ }
26
+
27
+ /**
28
+ * Reload Pi after giving custom overlays a turn to resolve and unmount.
29
+ *
30
+ * Reload failures are reported to the user instead of escaping the calling
31
+ * editor or picker flow. Callers should resolve their overlay before invoking
32
+ * this helper; otherwise stale TUI components may survive the extension reload.
33
+ */
34
+ export function reloadAfterOverlayClose(ctx: ReloadContext): void {
35
+ const { reload } = ctx;
36
+
37
+ if (typeof reload !== "function") return;
38
+
39
+ setTimeout(() => {
40
+ void reloadPi({ reload, ui: ctx.ui });
41
+ }, 0);
42
+ }
43
+
44
+ function formatError(error: unknown): string {
45
+ if (error instanceof Error) return error.message;
46
+ if (typeof error === "string") return error;
47
+
48
+ return "unknown error";
49
+ }
50
+
51
+ async function reloadPi(
52
+ ctx: Required<Pick<ReloadContext, "reload" | "ui">>,
53
+ ): Promise<void> {
54
+ try {
55
+ await ctx.reload();
56
+ } catch (error) {
57
+ ctx.ui.notify(`Failed to reload Pi: ${formatError(error)}.`, "error");
58
+ }
59
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Status-bar rendering for active presets.
3
+ *
4
+ * Owns the compact `presets-plus` footer status entry; it does NOT
5
+ * compute drift or mutate active state.
6
+ */
7
+ import type { ActivePresetState, LoadedPreset } from "../types.js";
8
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
9
+
10
+ const STATUS_KEY = "presets-plus";
11
+
12
+ /** Minimal context surface needed to update footer status. */
13
+ type StatusContext = Pick<ExtensionContext, "ui">;
14
+
15
+ /** Render or clear the active-preset status badge. */
16
+ export function updateStatus(
17
+ ctx: StatusContext,
18
+ active: ActivePresetState | undefined,
19
+ lookup: (
20
+ name: string,
21
+ scope: ActivePresetState["scope"],
22
+ ) => LoadedPreset | undefined,
23
+ ): void {
24
+ if (!active) {
25
+ ctx.ui.setStatus(STATUS_KEY, dim(ctx, "Preset: none"));
26
+
27
+ return;
28
+ }
29
+
30
+ const preset = lookup(active.name, active.scope);
31
+
32
+ if (!preset) {
33
+ ctx.ui.setStatus(STATUS_KEY, dim(ctx, "Preset: none"));
34
+
35
+ return;
36
+ }
37
+
38
+ const label = dim(ctx, `Preset: ${preset.name}`);
39
+
40
+ if (!active.dirty) {
41
+ ctx.ui.setStatus(STATUS_KEY, label);
42
+
43
+ return;
44
+ }
45
+
46
+ ctx.ui.setStatus(STATUS_KEY, `${label}${warning(ctx, "!")}`);
47
+ }
48
+
49
+ function dim(ctx: StatusContext, text: string): string {
50
+ return ctx.ui.theme?.fg("dim", text) ?? text;
51
+ }
52
+
53
+ function warning(ctx: StatusContext, text: string): string {
54
+ return ctx.ui.theme?.fg("warning", text) ?? text;
55
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Reusable preset-picker widget primitives.
3
+ *
4
+ * Owns readable key/value preset card rendering; it does NOT own picker
5
+ * state, keyboard handling, or activation.
6
+ */
7
+ import type { LoadedPreset } from "../types.js";
8
+ import {
9
+ MODEL_LABEL,
10
+ SCOPE_LABEL,
11
+ STATUS_LABEL,
12
+ THINKING_LABEL,
13
+ TOOLS_LABEL,
14
+ } from "./labels.js";
15
+ import type { Theme } from "@mariozechner/pi-coding-agent";
16
+ import { truncateToWidth, type Component } from "@mariozechner/pi-tui";
17
+
18
+ export interface PresetCardOptions {
19
+ active: boolean;
20
+ dirty?: boolean;
21
+ driftReasons?: readonly string[];
22
+ inheritedTools?: readonly string[];
23
+ selected: boolean;
24
+ showShadowed?: boolean;
25
+ }
26
+
27
+ /**
28
+ * Minimum theme surface needed by the preset card and its formatters.
29
+ *
30
+ * Restricting to `fg` + `bold` (matching the `Styler` pattern in
31
+ * `activation/clear.ts`) lets tests pass an honest stub without an
32
+ * `as unknown as Theme` cast and makes future Theme additions
33
+ * visible at the call sites that actually need them.
34
+ */
35
+ type CardTheme = Pick<Theme, "fg" | "bold">;
36
+
37
+ type ThinkingColor = Parameters<Theme["fg"]>[0];
38
+
39
+ const FIELD_LABEL_WIDTH = Math.max(
40
+ "Shadowing:".length,
41
+ `${THINKING_LABEL}:`.length,
42
+ );
43
+ const PROMPT_PREVIEW_WIDTH = 60;
44
+
45
+ class PresetCardComponent implements Component {
46
+ constructor(
47
+ private readonly loadedPreset: LoadedPreset,
48
+ private readonly theme: CardTheme,
49
+ private readonly options: PresetCardOptions,
50
+ ) {}
51
+
52
+ invalidate(): void {}
53
+
54
+ render(width: number): string[] {
55
+ const titlePrefix = this.options.selected
56
+ ? this.theme.fg("accent", "▌ ")
57
+ : " ";
58
+ const dot = this.theme.fg(
59
+ this.options.active ? "success" : "dim",
60
+ formatStatusDot(this.options.active),
61
+ );
62
+ const displayName = this.options.active
63
+ ? this.theme.fg("accent", this.theme.bold(this.loadedPreset.name))
64
+ : this.theme.fg("text", this.loadedPreset.name);
65
+ const lines = [`${titlePrefix}${dot} ${displayName}`];
66
+
67
+ lines.push(
68
+ this.renderField(`${SCOPE_LABEL}:`, formatScopeValue(this.loadedPreset)),
69
+ );
70
+
71
+ lines.push(
72
+ this.renderField(
73
+ `${MODEL_LABEL}:`,
74
+ `${this.loadedPreset.provider} / ${this.loadedPreset.model}`,
75
+ ),
76
+ );
77
+
78
+ lines.push(
79
+ this.renderField(
80
+ `${THINKING_LABEL}:`,
81
+ this.theme.fg(
82
+ thinkingColor(this.loadedPreset.thinkingLevel ?? "off"),
83
+ formatThinkingLevel(this.loadedPreset.thinkingLevel ?? "off"),
84
+ ),
85
+ ),
86
+ );
87
+
88
+ lines.push(
89
+ this.renderField(
90
+ `${TOOLS_LABEL}:`,
91
+ formatToolsSummary(
92
+ this.loadedPreset.tools,
93
+ this.options.inheritedTools ?? [],
94
+ ),
95
+ ),
96
+ );
97
+
98
+ const promptPreview = formatInstructionsPreview(
99
+ this.loadedPreset.instructions,
100
+ );
101
+
102
+ if (promptPreview.length > 0) {
103
+ lines.push(this.renderField("Prompt:", promptPreview));
104
+ }
105
+
106
+ if (this.loadedPreset.clampWarning === true) {
107
+ lines.push(
108
+ this.renderField(
109
+ `${STATUS_LABEL}:`,
110
+ this.theme.fg("warning", "⚠ Thinking will be clamped."),
111
+ ),
112
+ );
113
+ }
114
+
115
+ if (this.loadedPreset.hotkeyConflict === true) {
116
+ lines.push(
117
+ this.renderField(
118
+ `${STATUS_LABEL}:`,
119
+ this.theme.fg("warning", "⚠ Hotkey conflict."),
120
+ ),
121
+ );
122
+ }
123
+
124
+ if (this.loadedPreset.hotkeyShadowsBuiltin === true) {
125
+ lines.push(
126
+ this.renderField(
127
+ `${STATUS_LABEL}:`,
128
+ this.theme.fg("warning", "⚠ Hotkey shadows a Pi built-in."),
129
+ ),
130
+ );
131
+ }
132
+
133
+ const availabilityStatus = formatAvailabilityStatus(this.loadedPreset);
134
+
135
+ if (availabilityStatus.length > 0) {
136
+ lines.push(
137
+ this.renderField(
138
+ `${STATUS_LABEL}:`,
139
+ this.theme.fg("warning", availabilityStatus),
140
+ ),
141
+ );
142
+ }
143
+
144
+ if (
145
+ this.options.active &&
146
+ this.options.dirty &&
147
+ this.options.driftReasons &&
148
+ this.options.driftReasons.length > 0
149
+ ) {
150
+ lines.push(
151
+ this.renderField(
152
+ "Drift:",
153
+ this.theme.fg(
154
+ "warning",
155
+ `⚠ Dirty — ${this.options.driftReasons.join(", ")} differ`,
156
+ ),
157
+ ),
158
+ );
159
+ }
160
+
161
+ if (this.loadedPreset.shadowed && this.options.showShadowed !== false) {
162
+ lines.push(
163
+ this.renderField(
164
+ "Shadowing:",
165
+ this.theme.fg("dim", "Overridden by project preset"),
166
+ ),
167
+ );
168
+ }
169
+
170
+ return lines.map((line) => truncateToWidth(line, width, "…"));
171
+ }
172
+
173
+ private renderField(label: string, value: string): string {
174
+ const padding = " ".repeat(Math.max(0, FIELD_LABEL_WIDTH - label.length));
175
+
176
+ return ` ${this.theme.fg("muted", label)}${padding} ${value}`;
177
+ }
178
+ }
179
+
180
+ export function formatAvailabilityStatus(loadedPreset: LoadedPreset): string {
181
+ switch (loadedPreset.unavailable) {
182
+ case "no-key":
183
+ return "⚠ This preset's provider has no API key configured.";
184
+ case "no-model":
185
+ return "⚠ This preset's model is no longer available.";
186
+ case undefined:
187
+ return "";
188
+ default:
189
+ return "";
190
+ }
191
+ }
192
+
193
+ export function formatInstructionsPreview(
194
+ instructions: string | undefined,
195
+ ): string {
196
+ if (!instructions) return "";
197
+
198
+ const singleLine = instructions.replaceAll(/\s+/g, " ").trim();
199
+
200
+ if (singleLine.length <= PROMPT_PREVIEW_WIDTH) return singleLine;
201
+
202
+ return `${singleLine.slice(0, PROMPT_PREVIEW_WIDTH - 1).trimEnd()}…`;
203
+ }
204
+
205
+ export function formatScopeValue(loadedPreset: LoadedPreset): string {
206
+ return loadedPreset.scope === "project" ? "Project" : "User";
207
+ }
208
+
209
+ export function formatStatusDot(active: boolean): string {
210
+ return active ? "●" : " ";
211
+ }
212
+
213
+ export function formatThinkingLevel(
214
+ level: NonNullable<LoadedPreset["thinkingLevel"]>,
215
+ ): string {
216
+ switch (level) {
217
+ case "minimal":
218
+ return "Minimal";
219
+ case "low":
220
+ return "Low";
221
+ case "medium":
222
+ return "Medium";
223
+ case "high":
224
+ return "High";
225
+ case "xhigh":
226
+ return "X-High";
227
+ case "off":
228
+ return "Off";
229
+ }
230
+ }
231
+
232
+ export function formatToolsSummary(
233
+ tools: readonly string[] | undefined,
234
+ inheritedTools: readonly string[] = [],
235
+ ): string {
236
+ if (tools && tools.length > 0) return `Preset: ${tools.join(", ")}`;
237
+ if (inheritedTools.length === 0) return "Session";
238
+
239
+ return `Session: ${inheritedTools.join(", ")}`;
240
+ }
241
+
242
+ /**
243
+ * Multi-line component for a single loaded preset.
244
+ *
245
+ * The card is intentionally stateless: callers pass active/selected flags on
246
+ * construction and rebuild cards when state changes. This keeps rendering
247
+ * deterministic and easy for future editor dialogs to share.
248
+ */
249
+ export function presetCard(
250
+ loadedPreset: LoadedPreset,
251
+ theme: CardTheme,
252
+ options: PresetCardOptions,
253
+ ): Component {
254
+ return new PresetCardComponent(loadedPreset, theme, options);
255
+ }
256
+
257
+ function thinkingColor(
258
+ level: NonNullable<LoadedPreset["thinkingLevel"]>,
259
+ ): ThinkingColor {
260
+ switch (level) {
261
+ case "minimal":
262
+ return "thinkingMinimal";
263
+ case "low":
264
+ return "thinkingLow";
265
+ case "medium":
266
+ return "thinkingMedium";
267
+ case "high":
268
+ return "thinkingHigh";
269
+ case "xhigh":
270
+ return "thinkingXhigh";
271
+ case "off":
272
+ return "thinkingOff";
273
+ }
274
+ }