@aliou/pi-utils-settings 0.3.0 → 0.5.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.
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Setup wizard for the example extension.
3
+ *
4
+ * Demonstrates the Wizard component:
5
+ * - All steps shown as tabs in a single bordered frame
6
+ * - Tab/Shift+Tab navigates between steps
7
+ * - Each step has its own inner Component
8
+ * - Steps call markComplete() when they have valid data
9
+ * - Ctrl+S submits, Esc cancels
10
+ */
11
+
12
+ import {
13
+ FuzzySelector,
14
+ Wizard,
15
+ type WizardStepContext,
16
+ } from "@aliou/pi-utils-settings";
17
+ import type {
18
+ ExtensionAPI,
19
+ ExtensionContext,
20
+ } from "@mariozechner/pi-coding-agent";
21
+ import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
22
+ import type { Component, SettingsListTheme } from "@mariozechner/pi-tui";
23
+ import { Input, Key, matchesKey } from "@mariozechner/pi-tui";
24
+ import { configLoader, type ExampleConfig } from "../config";
25
+
26
+ // --- Collected wizard state ---
27
+ // Shared mutable object that each step writes into.
28
+
29
+ interface WizardState {
30
+ theme: string | null;
31
+ favorite: string | null;
32
+ autoSave: boolean;
33
+ formatOnSave: boolean;
34
+ }
35
+
36
+ // --- Step components ---
37
+ // Each step reads/writes to the shared WizardState and calls
38
+ // markComplete()/markIncomplete() to update progress indicators.
39
+
40
+ class ThemeStep implements Component {
41
+ private selector: FuzzySelector;
42
+
43
+ constructor(
44
+ state: WizardState,
45
+ settingsTheme: SettingsListTheme,
46
+ wizardCtx: WizardStepContext,
47
+ ) {
48
+ this.selector = new FuzzySelector({
49
+ label: "Pick a theme",
50
+ items: [
51
+ "dark",
52
+ "light",
53
+ "solarized-dark",
54
+ "solarized-light",
55
+ "monokai",
56
+ "nord",
57
+ "dracula",
58
+ "gruvbox",
59
+ "catppuccin",
60
+ "tokyo-night",
61
+ ],
62
+ currentValue: state.theme ?? undefined,
63
+ theme: settingsTheme,
64
+ onSelect: (selected) => {
65
+ state.theme = selected;
66
+ wizardCtx.markComplete();
67
+ wizardCtx.goNext();
68
+ },
69
+ // onDone is a no-op here; the Wizard handles Esc globally
70
+ onDone: () => {},
71
+ });
72
+ }
73
+
74
+ render(width: number): string[] {
75
+ return this.selector.render(width);
76
+ }
77
+
78
+ invalidate(): void {
79
+ this.selector.invalidate?.();
80
+ }
81
+
82
+ handleInput(data: string): void {
83
+ // Filter out keys the Wizard handles globally
84
+ if (matchesKey(data, Key.escape)) return;
85
+ this.selector.handleInput(data);
86
+ }
87
+ }
88
+
89
+ class FavoriteStep implements Component {
90
+ private input: Input;
91
+ private settingsTheme: SettingsListTheme;
92
+
93
+ constructor(
94
+ private state: WizardState,
95
+ settingsTheme: SettingsListTheme,
96
+ wizardCtx: WizardStepContext,
97
+ ) {
98
+ this.settingsTheme = settingsTheme;
99
+ this.input = new Input();
100
+ if (state.favorite) this.input.setValue(state.favorite);
101
+
102
+ this.input.onSubmit = () => {
103
+ const value = this.input.getValue().trim();
104
+ state.favorite = value || null;
105
+ if (value) wizardCtx.markComplete();
106
+ else wizardCtx.markIncomplete();
107
+ wizardCtx.goNext();
108
+ };
109
+ }
110
+
111
+ render(width: number): string[] {
112
+ const lines: string[] = [];
113
+ lines.push(this.settingsTheme.label(" Add a favorite", true));
114
+ lines.push("");
115
+ lines.push(
116
+ this.settingsTheme.hint(" Enter an item (optional, Enter to confirm):"),
117
+ );
118
+ lines.push(` ${this.input.render(width - 4).join("")}`);
119
+
120
+ if (this.state.favorite) {
121
+ lines.push("");
122
+ lines.push(this.settingsTheme.hint(` Current: ${this.state.favorite}`));
123
+ }
124
+
125
+ return lines;
126
+ }
127
+
128
+ invalidate() {}
129
+
130
+ handleInput(data: string): void {
131
+ if (matchesKey(data, Key.escape)) return;
132
+ this.input.handleInput(data);
133
+ }
134
+ }
135
+
136
+ class EditorFeaturesStep implements Component {
137
+ private items: Array<{
138
+ label: string;
139
+ value: keyof WizardState;
140
+ selected: boolean;
141
+ }>;
142
+ private settingsTheme: SettingsListTheme;
143
+ private selectedIndex = 0;
144
+
145
+ constructor(
146
+ private state: WizardState,
147
+ settingsTheme: SettingsListTheme,
148
+ wizardCtx: WizardStepContext,
149
+ ) {
150
+ this.settingsTheme = settingsTheme;
151
+ this.items = [
152
+ { label: "Auto save", value: "autoSave", selected: state.autoSave },
153
+ {
154
+ label: "Format on save",
155
+ value: "formatOnSave",
156
+ selected: state.formatOnSave,
157
+ },
158
+ ];
159
+ // Always complete since toggles have defaults
160
+ wizardCtx.markComplete();
161
+ }
162
+
163
+ render(_width: number): string[] {
164
+ const lines: string[] = [];
165
+ lines.push(this.settingsTheme.label(" Editor features", true));
166
+ lines.push("");
167
+
168
+ for (let i = 0; i < this.items.length; i++) {
169
+ const item = this.items[i];
170
+ if (!item) continue;
171
+ const isSelected = i === this.selectedIndex;
172
+ const prefix = isSelected ? this.settingsTheme.cursor : " ";
173
+ const check = item.selected ? "[x]" : "[ ]";
174
+ const label = this.settingsTheme.value(
175
+ `${check} ${item.label}`,
176
+ isSelected,
177
+ );
178
+ lines.push(`${prefix}${label}`);
179
+ }
180
+
181
+ return lines;
182
+ }
183
+
184
+ invalidate() {}
185
+
186
+ handleInput(data: string): void {
187
+ if (matchesKey(data, Key.up)) {
188
+ this.selectedIndex =
189
+ this.selectedIndex === 0
190
+ ? this.items.length - 1
191
+ : this.selectedIndex - 1;
192
+ } else if (matchesKey(data, Key.down)) {
193
+ this.selectedIndex =
194
+ this.selectedIndex === this.items.length - 1
195
+ ? 0
196
+ : this.selectedIndex + 1;
197
+ } else if (data === " " || matchesKey(data, Key.enter)) {
198
+ const item = this.items[this.selectedIndex];
199
+ if (item) {
200
+ item.selected = !item.selected;
201
+ // Write back to shared state
202
+ this.state[item.value] = item.selected as never;
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ // --- Command registration ---
209
+
210
+ export function registerExampleSetup(
211
+ pi: ExtensionAPI,
212
+ onConfigChange: (ctx: ExtensionContext) => void,
213
+ ): void {
214
+ pi.registerCommand("example:setup", {
215
+ description: "First-time setup wizard for example extension",
216
+ handler: async (_args, ctx) => {
217
+ const settingsTheme = getSettingsListTheme();
218
+ const currentConfig = configLoader.getConfig();
219
+
220
+ // Shared state across all wizard steps
221
+ const state: WizardState = {
222
+ theme: currentConfig.appearance.theme,
223
+ favorite: null,
224
+ autoSave: currentConfig.editor.autoSave,
225
+ formatOnSave: currentConfig.editor.formatOnSave,
226
+ };
227
+
228
+ const saved = await ctx.ui.custom<boolean>((_tui, uiTheme, _kb, done) => {
229
+ return new Wizard({
230
+ title: "Example Setup",
231
+ theme: uiTheme,
232
+ onComplete: () => done(true),
233
+ onCancel: () => done(false),
234
+ steps: [
235
+ {
236
+ label: "Theme",
237
+ build: (wizardCtx) => {
238
+ // Pre-mark complete if we have a default
239
+ if (state.theme) wizardCtx.markComplete();
240
+ return new ThemeStep(state, settingsTheme, wizardCtx);
241
+ },
242
+ },
243
+ {
244
+ label: "Favorite",
245
+ build: (wizardCtx) =>
246
+ new FavoriteStep(state, settingsTheme, wizardCtx),
247
+ },
248
+ {
249
+ label: "Editor",
250
+ build: (wizardCtx) =>
251
+ new EditorFeaturesStep(state, settingsTheme, wizardCtx),
252
+ },
253
+ ],
254
+ });
255
+ });
256
+
257
+ if (!saved) return;
258
+
259
+ // Build config from wizard state
260
+ const newConfig: ExampleConfig = {
261
+ appearance: { theme: state.theme ?? undefined },
262
+ editor: {
263
+ autoSave: state.autoSave,
264
+ formatOnSave: state.formatOnSave,
265
+ },
266
+ };
267
+ if (state.favorite) {
268
+ newConfig.favorites = [state.favorite];
269
+ }
270
+
271
+ await configLoader.save("global", newConfig);
272
+ onConfigChange(ctx);
273
+ ctx.ui.notify("Setup complete", "info");
274
+ },
275
+ });
276
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Configuration for the example extension.
3
+ *
4
+ * Demonstrates:
5
+ * - Partial (user-facing) vs resolved (internal) config types
6
+ * - Multiple scopes (global + local)
7
+ * - Migrations for schema evolution
8
+ * - afterMerge hook for custom merge logic
9
+ */
10
+
11
+ import { ConfigLoader, type Migration } from "@aliou/pi-utils-settings";
12
+
13
+ // --- User-facing config (all optional, stored on disk) ---
14
+
15
+ export interface ExampleConfig {
16
+ appearance?: {
17
+ theme?: string;
18
+ fontSize?: number;
19
+ showLineNumbers?: boolean;
20
+ };
21
+ editor?: {
22
+ autoSave?: boolean;
23
+ formatOnSave?: boolean;
24
+ tabSize?: number;
25
+ };
26
+ favorites?: string[];
27
+ ignorePaths?: string[];
28
+ }
29
+
30
+ // --- Resolved config (all required, defaults applied) ---
31
+
32
+ export interface ResolvedExampleConfig {
33
+ appearance: {
34
+ theme: string;
35
+ fontSize: number;
36
+ showLineNumbers: boolean;
37
+ };
38
+ editor: {
39
+ autoSave: boolean;
40
+ formatOnSave: boolean;
41
+ tabSize: number;
42
+ };
43
+ favorites: string[];
44
+ ignorePaths: string[];
45
+ }
46
+
47
+ // --- Defaults ---
48
+
49
+ const DEFAULT_CONFIG: ResolvedExampleConfig = {
50
+ appearance: {
51
+ theme: "dark",
52
+ fontSize: 14,
53
+ showLineNumbers: true,
54
+ },
55
+ editor: {
56
+ autoSave: false,
57
+ formatOnSave: true,
58
+ tabSize: 2,
59
+ },
60
+ favorites: [],
61
+ ignorePaths: [],
62
+ };
63
+
64
+ // --- Migrations ---
65
+
66
+ const migrations: Migration<ExampleConfig>[] = [
67
+ {
68
+ name: "rename-font-size",
69
+ shouldRun: (config) => "fontsize" in (config.appearance ?? {}),
70
+ run: (config) => {
71
+ const appearance = config.appearance ?? {};
72
+ const fontSize = (appearance as Record<string, unknown>)["fontsize"];
73
+ const { fontsize: _, ...rest } = appearance as Record<string, unknown>;
74
+ return {
75
+ ...config,
76
+ appearance: { ...rest, fontSize } as ExampleConfig["appearance"],
77
+ };
78
+ },
79
+ },
80
+ ];
81
+
82
+ // --- Loader ---
83
+
84
+ export const configLoader = new ConfigLoader<
85
+ ExampleConfig,
86
+ ResolvedExampleConfig
87
+ >("example-extension", DEFAULT_CONFIG, {
88
+ scopes: ["global", "local"],
89
+ migrations,
90
+ afterMerge: (resolved, _global, local) => {
91
+ // Example: local ignorePaths replace global rather than merge
92
+ if (local?.ignorePaths) {
93
+ resolved.ignorePaths = local.ignorePaths;
94
+ }
95
+ return resolved;
96
+ },
97
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Example extension entry point.
3
+ *
4
+ * Demonstrates the typical activation pattern:
5
+ * 1. Load config
6
+ * 2. Register settings command (edit existing config)
7
+ * 3. Register setup command (first-time wizard)
8
+ * 4. Use resolved config for runtime behavior
9
+ */
10
+
11
+ import type {
12
+ ExtensionAPI,
13
+ ExtensionContext,
14
+ } from "@mariozechner/pi-coding-agent";
15
+ import { registerExampleSettings } from "./commands/settings";
16
+ import { registerExampleSetup } from "./commands/setup";
17
+ import { configLoader } from "./config";
18
+
19
+ export default async function activate(pi: ExtensionAPI) {
20
+ // 1. Load config (reads from disk, applies migrations, merges scopes)
21
+ await configLoader.load();
22
+
23
+ // 2. Register settings command: /example:settings
24
+ registerExampleSettings(pi);
25
+
26
+ // 3. Register setup command: /example:setup
27
+ registerExampleSetup(pi, handleConfigChange);
28
+
29
+ // 4. Use config at runtime
30
+ // Ready to use config.appearance.theme, config.editor, etc.
31
+ }
32
+
33
+ function handleConfigChange(_ctx: ExtensionContext): void {
34
+ // Called after setup wizard saves. Reload any cached runtime state.
35
+ // e.g. const config = configLoader.getConfig();
36
+ }