@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.
- package/components/fuzzy-selector.ts +2 -0
- package/components/sectioned-settings.ts +7 -1
- package/components/wizard.ts +254 -0
- package/index.ts +7 -0
- package/package.json +7 -1
- package/settings-command.ts +76 -11
- package/skills/pi-utils-settings/SKILL.md +290 -0
- package/skills/pi-utils-settings/references/example-extension/commands/settings.ts +268 -0
- package/skills/pi-utils-settings/references/example-extension/commands/setup.ts +276 -0
- package/skills/pi-utils-settings/references/example-extension/config.ts +97 -0
- package/skills/pi-utils-settings/references/example-extension/index.ts +36 -0
|
@@ -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
|
+
}
|