@aliou/pi-utils-settings 0.4.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 +70 -8
- 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
|
@@ -165,6 +165,8 @@ export class FuzzySelector implements Component {
|
|
|
165
165
|
this.selectedIndex === this.filteredItems.length - 1
|
|
166
166
|
? 0
|
|
167
167
|
: this.selectedIndex + 1;
|
|
168
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
169
|
+
this.selectCurrent();
|
|
168
170
|
} else if (matchesKey(data, Key.escape)) {
|
|
169
171
|
this.onDone();
|
|
170
172
|
} else {
|
|
@@ -27,6 +27,8 @@ export interface SectionedSettingsOptions {
|
|
|
27
27
|
enableSearch?: boolean;
|
|
28
28
|
/** Extra text appended to the hint line (e.g. "Ctrl+S to save"). */
|
|
29
29
|
hintSuffix?: string;
|
|
30
|
+
/** Hide the built-in hint line (when the parent renders its own controls). */
|
|
31
|
+
hideHint?: boolean;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
interface FlatEntry {
|
|
@@ -47,6 +49,7 @@ export class SectionedSettings implements Component {
|
|
|
47
49
|
private searchInput?: Input;
|
|
48
50
|
private searchEnabled: boolean;
|
|
49
51
|
private hintSuffix: string;
|
|
52
|
+
private hideHint: boolean;
|
|
50
53
|
private submenuComponent: Component | null = null;
|
|
51
54
|
private submenuItemIndex: number | null = null;
|
|
52
55
|
|
|
@@ -65,6 +68,7 @@ export class SectionedSettings implements Component {
|
|
|
65
68
|
this.onCancel = onCancel;
|
|
66
69
|
this.searchEnabled = options.enableSearch ?? false;
|
|
67
70
|
this.hintSuffix = options.hintSuffix ?? "";
|
|
71
|
+
this.hideHint = options.hideHint ?? false;
|
|
68
72
|
this.selectedIndex = 0;
|
|
69
73
|
|
|
70
74
|
if (this.searchEnabled) {
|
|
@@ -259,7 +263,9 @@ export class SectionedSettings implements Component {
|
|
|
259
263
|
}
|
|
260
264
|
}
|
|
261
265
|
|
|
262
|
-
this.
|
|
266
|
+
if (!this.hideHint) {
|
|
267
|
+
this.addHintLine(lines);
|
|
268
|
+
}
|
|
263
269
|
return lines;
|
|
264
270
|
}
|
|
265
271
|
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A multi-step wizard component with tabbed navigation and bordered frame.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Box-drawing border around the entire wizard (╭╮╰╯ style)
|
|
6
|
+
* - Step tabs at the top with progress indicators (●/○)
|
|
7
|
+
* - Tab/Shift+Tab navigation between steps
|
|
8
|
+
* - Each step renders its own inner Component
|
|
9
|
+
* - Ctrl+S or Enter on the last step submits
|
|
10
|
+
* - Esc cancels the entire wizard
|
|
11
|
+
*
|
|
12
|
+
* Use for first-time setup flows, multi-step configuration, or any
|
|
13
|
+
* sequential data collection that benefits from back-and-forth navigation.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
17
|
+
import type { Component } from "@mariozechner/pi-tui";
|
|
18
|
+
import {
|
|
19
|
+
Key,
|
|
20
|
+
matchesKey,
|
|
21
|
+
truncateToWidth,
|
|
22
|
+
visibleWidth,
|
|
23
|
+
} from "@mariozechner/pi-tui";
|
|
24
|
+
|
|
25
|
+
export interface WizardStep {
|
|
26
|
+
/** Tab label shown at the top. Keep it short (1-2 words). */
|
|
27
|
+
label: string;
|
|
28
|
+
/**
|
|
29
|
+
* Build the inner component for this step.
|
|
30
|
+
*
|
|
31
|
+
* The component controls its own rendering and input handling.
|
|
32
|
+
* Call `markComplete()` to mark the step as done (shows ● in tabs).
|
|
33
|
+
* Call `markIncomplete()` to revert it (shows ○).
|
|
34
|
+
*
|
|
35
|
+
* The wizard handles borders, tabs, and step navigation externally.
|
|
36
|
+
* The inner component should NOT handle Tab/Shift+Tab/Esc/Ctrl+S.
|
|
37
|
+
*/
|
|
38
|
+
build: (ctx: WizardStepContext) => Component;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WizardStepContext {
|
|
42
|
+
/** Mark this step as complete (filled ● in progress). */
|
|
43
|
+
markComplete: () => void;
|
|
44
|
+
/** Mark this step as incomplete (empty ○ in progress). */
|
|
45
|
+
markIncomplete: () => void;
|
|
46
|
+
/** Advance to the next step (wraps around). */
|
|
47
|
+
goNext: () => void;
|
|
48
|
+
/** Go back to the previous step (wraps around). */
|
|
49
|
+
goPrev: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface WizardOptions {
|
|
53
|
+
/** Title shown at the top of the wizard. */
|
|
54
|
+
title: string;
|
|
55
|
+
/** Ordered list of steps. */
|
|
56
|
+
steps: WizardStep[];
|
|
57
|
+
/** Theme for rendering borders and chrome. */
|
|
58
|
+
theme: Theme;
|
|
59
|
+
/** Called when user submits (Ctrl+S). */
|
|
60
|
+
onComplete: () => void;
|
|
61
|
+
/** Called when user cancels (Esc). */
|
|
62
|
+
onCancel: () => void;
|
|
63
|
+
/** Hint text appended to the controls line. */
|
|
64
|
+
hintSuffix?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class Wizard implements Component {
|
|
68
|
+
private steps: WizardStep[];
|
|
69
|
+
private theme: Theme;
|
|
70
|
+
private onComplete: () => void;
|
|
71
|
+
private onCancel: () => void;
|
|
72
|
+
private title: string;
|
|
73
|
+
private hintSuffix: string;
|
|
74
|
+
|
|
75
|
+
private activeIndex = 0;
|
|
76
|
+
private completed: boolean[];
|
|
77
|
+
private components: Component[];
|
|
78
|
+
|
|
79
|
+
constructor(options: WizardOptions) {
|
|
80
|
+
this.steps = options.steps;
|
|
81
|
+
this.theme = options.theme;
|
|
82
|
+
this.onComplete = options.onComplete;
|
|
83
|
+
this.onCancel = options.onCancel;
|
|
84
|
+
this.title = options.title;
|
|
85
|
+
this.hintSuffix = options.hintSuffix ?? "";
|
|
86
|
+
|
|
87
|
+
this.completed = new Array(options.steps.length).fill(false) as boolean[];
|
|
88
|
+
this.components = options.steps.map((step, i) =>
|
|
89
|
+
step.build({
|
|
90
|
+
markComplete: () => {
|
|
91
|
+
this.completed[i] = true;
|
|
92
|
+
},
|
|
93
|
+
markIncomplete: () => {
|
|
94
|
+
this.completed[i] = false;
|
|
95
|
+
},
|
|
96
|
+
goNext: () => {
|
|
97
|
+
this.activeIndex = (this.activeIndex + 1) % this.steps.length;
|
|
98
|
+
},
|
|
99
|
+
goPrev: () => {
|
|
100
|
+
this.activeIndex =
|
|
101
|
+
(this.activeIndex - 1 + this.steps.length) % this.steps.length;
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Returns the index of the currently active step. */
|
|
108
|
+
getActiveIndex(): number {
|
|
109
|
+
return this.activeIndex;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Returns whether all steps are marked complete. */
|
|
113
|
+
isAllComplete(): boolean {
|
|
114
|
+
return this.completed.every(Boolean);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
invalidate(): void {
|
|
118
|
+
for (const component of this.components) {
|
|
119
|
+
component.invalidate?.();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
render(width: number): string[] {
|
|
124
|
+
const lines: string[] = [];
|
|
125
|
+
const t = this.theme;
|
|
126
|
+
const contentWidth = Math.max(1, width - 2);
|
|
127
|
+
|
|
128
|
+
// --- Top border with title ---
|
|
129
|
+
const titleText = ` ${this.title} `;
|
|
130
|
+
const titleLen = visibleWidth(titleText);
|
|
131
|
+
const topRuleLen = Math.max(1, width - titleLen - 3);
|
|
132
|
+
lines.push(
|
|
133
|
+
t.fg("border", "╭─") +
|
|
134
|
+
t.fg("accent", t.bold(titleText)) +
|
|
135
|
+
t.fg("border", "─".repeat(topRuleLen)) +
|
|
136
|
+
t.fg("border", "╮"),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// --- Step tabs with progress ---
|
|
140
|
+
const tabLine = this.renderStepTabs(contentWidth);
|
|
141
|
+
lines.push(this.padLine(tabLine, contentWidth));
|
|
142
|
+
lines.push(this.padLine("", contentWidth));
|
|
143
|
+
|
|
144
|
+
// --- Inner component ---
|
|
145
|
+
const innerComponent = this.components[this.activeIndex];
|
|
146
|
+
if (innerComponent) {
|
|
147
|
+
const innerLines = innerComponent.render(contentWidth);
|
|
148
|
+
for (const line of innerLines) {
|
|
149
|
+
lines.push(this.padLine(line, contentWidth));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Separator ---
|
|
154
|
+
lines.push(
|
|
155
|
+
t.fg("border", "├") +
|
|
156
|
+
t.fg("border", "─".repeat(contentWidth)) +
|
|
157
|
+
t.fg("border", "┤"),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// --- Controls ---
|
|
161
|
+
const controlsText = this.buildControlsText();
|
|
162
|
+
lines.push(this.padLine(controlsText, contentWidth));
|
|
163
|
+
|
|
164
|
+
// --- Bottom border ---
|
|
165
|
+
lines.push(
|
|
166
|
+
t.fg("border", "╰") +
|
|
167
|
+
t.fg("border", "─".repeat(contentWidth)) +
|
|
168
|
+
t.fg("border", "╯"),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
handleInput(data: string): void {
|
|
175
|
+
// Ctrl+S: submit if all steps complete
|
|
176
|
+
if (matchesKey(data, Key.ctrl("s"))) {
|
|
177
|
+
this.onComplete();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Esc: cancel
|
|
182
|
+
if (matchesKey(data, Key.escape)) {
|
|
183
|
+
this.onCancel();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Tab / Shift+Tab: navigate steps
|
|
188
|
+
if (matchesKey(data, Key.tab)) {
|
|
189
|
+
this.activeIndex = (this.activeIndex + 1) % this.steps.length;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (matchesKey(data, Key.shift("tab"))) {
|
|
193
|
+
this.activeIndex =
|
|
194
|
+
(this.activeIndex - 1 + this.steps.length) % this.steps.length;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Delegate to inner component
|
|
199
|
+
const innerComponent = this.components[this.activeIndex];
|
|
200
|
+
innerComponent?.handleInput?.(data);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// --- Private rendering helpers ---
|
|
204
|
+
|
|
205
|
+
private renderStepTabs(_contentWidth: number): string {
|
|
206
|
+
const t = this.theme;
|
|
207
|
+
const parts: string[] = [];
|
|
208
|
+
|
|
209
|
+
for (let i = 0; i < this.steps.length; i++) {
|
|
210
|
+
const step = this.steps[i];
|
|
211
|
+
if (!step) continue;
|
|
212
|
+
|
|
213
|
+
const dot = this.completed[i]
|
|
214
|
+
? t.fg("success", "●")
|
|
215
|
+
: i === this.activeIndex
|
|
216
|
+
? t.fg("accent", "●")
|
|
217
|
+
: t.fg("dim", "○");
|
|
218
|
+
|
|
219
|
+
const label =
|
|
220
|
+
i === this.activeIndex
|
|
221
|
+
? t.bg("selectedBg", t.fg("accent", ` ${step.label} `))
|
|
222
|
+
: t.fg("dim", ` ${step.label} `);
|
|
223
|
+
|
|
224
|
+
parts.push(`${dot} ${label}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return parts.join(" ");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private padLine(content: string, contentWidth: number): string {
|
|
231
|
+
const t = this.theme;
|
|
232
|
+
const len = visibleWidth(content);
|
|
233
|
+
const padding = Math.max(0, contentWidth - len);
|
|
234
|
+
return (
|
|
235
|
+
t.fg("border", "│") +
|
|
236
|
+
truncateToWidth(content, contentWidth) +
|
|
237
|
+
" ".repeat(padding) +
|
|
238
|
+
t.fg("border", "│")
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private buildControlsText(): string {
|
|
243
|
+
const t = this.theme;
|
|
244
|
+
const parts: string[] = [
|
|
245
|
+
"Tab/Shift+Tab navigate",
|
|
246
|
+
"Ctrl+S submit",
|
|
247
|
+
"Esc cancel",
|
|
248
|
+
];
|
|
249
|
+
if (this.hintSuffix) {
|
|
250
|
+
parts.push(this.hintSuffix);
|
|
251
|
+
}
|
|
252
|
+
return t.fg("dim", ` ${parts.join(" · ")}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
package/index.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Shared settings infrastructure for pi extensions:
|
|
5
5
|
* - ConfigLoader: load/save/merge JSON configs from global + project paths
|
|
6
6
|
* - registerSettingsCommand: create a settings command with Local/Global tabs
|
|
7
|
+
* - Wizard: multi-step wizard component with tabbed navigation and borders
|
|
7
8
|
* - SectionedSettings: sectioned settings list component
|
|
8
9
|
* - ArrayEditor: string array editor submenu component
|
|
9
10
|
* - Helpers: nested value access, display-to-storage value mapping
|
|
@@ -26,6 +27,12 @@ export {
|
|
|
26
27
|
type SectionedSettingsOptions,
|
|
27
28
|
type SettingsSection,
|
|
28
29
|
} from "./components/sectioned-settings";
|
|
30
|
+
export {
|
|
31
|
+
Wizard,
|
|
32
|
+
type WizardOptions,
|
|
33
|
+
type WizardStep,
|
|
34
|
+
type WizardStepContext,
|
|
35
|
+
} from "./components/wizard";
|
|
29
36
|
export {
|
|
30
37
|
ConfigLoader,
|
|
31
38
|
type ConfigStore,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliou/pi-utils-settings",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,8 +19,14 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"*.ts",
|
|
21
21
|
"components",
|
|
22
|
+
"skills",
|
|
22
23
|
"README.md"
|
|
23
24
|
],
|
|
25
|
+
"pi": {
|
|
26
|
+
"skills": [
|
|
27
|
+
"skills"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
24
30
|
"peerDependencies": {
|
|
25
31
|
"@mariozechner/pi-coding-agent": ">=0.51.0"
|
|
26
32
|
}
|
package/settings-command.ts
CHANGED
|
@@ -10,7 +10,12 @@ import type {
|
|
|
10
10
|
ExtensionCommandContext,
|
|
11
11
|
} from "@mariozechner/pi-coding-agent";
|
|
12
12
|
import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
Key,
|
|
15
|
+
matchesKey,
|
|
16
|
+
truncateToWidth,
|
|
17
|
+
visibleWidth,
|
|
18
|
+
} from "@mariozechner/pi-tui";
|
|
14
19
|
import {
|
|
15
20
|
SectionedSettings,
|
|
16
21
|
type SettingsSection,
|
|
@@ -200,7 +205,7 @@ export function registerSettingsCommand<
|
|
|
200
205
|
handleChange(scope, id, newValue);
|
|
201
206
|
},
|
|
202
207
|
() => done(undefined),
|
|
203
|
-
{ enableSearch: true,
|
|
208
|
+
{ enableSearch: true, hideHint: true },
|
|
204
209
|
);
|
|
205
210
|
}
|
|
206
211
|
|
|
@@ -269,10 +274,10 @@ export function registerSettingsCommand<
|
|
|
269
274
|
|
|
270
275
|
// --- Tab rendering ---
|
|
271
276
|
|
|
272
|
-
function renderTabs(): string
|
|
277
|
+
function renderTabs(_contentWidth: number): string {
|
|
273
278
|
// Single scope = no tabs needed
|
|
274
279
|
if (enabledScopes.length === 1) {
|
|
275
|
-
return
|
|
280
|
+
return "";
|
|
276
281
|
}
|
|
277
282
|
|
|
278
283
|
const tabLabels = enabledScopes.map((scope) => {
|
|
@@ -286,7 +291,18 @@ export function registerSettingsCommand<
|
|
|
286
291
|
return theme.fg("dim", fullLabel);
|
|
287
292
|
});
|
|
288
293
|
|
|
289
|
-
return
|
|
294
|
+
return tabLabels.join(" ");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function padLine(content: string, contentWidth: number): string {
|
|
298
|
+
const len = visibleWidth(content);
|
|
299
|
+
const padding = Math.max(0, contentWidth - len);
|
|
300
|
+
return (
|
|
301
|
+
theme.fg("border", "│") +
|
|
302
|
+
truncateToWidth(content, contentWidth) +
|
|
303
|
+
" ".repeat(padding) +
|
|
304
|
+
theme.fg("border", "│")
|
|
305
|
+
);
|
|
290
306
|
}
|
|
291
307
|
|
|
292
308
|
function handleTabSwitch(data: string): boolean {
|
|
@@ -314,9 +330,55 @@ export function registerSettingsCommand<
|
|
|
314
330
|
return {
|
|
315
331
|
render(width: number) {
|
|
316
332
|
const lines: string[] = [];
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
333
|
+
const contentWidth = Math.max(1, width - 2);
|
|
334
|
+
|
|
335
|
+
// Top border with title
|
|
336
|
+
const titleText = ` ${title} `;
|
|
337
|
+
const titleLen = visibleWidth(titleText);
|
|
338
|
+
const topRuleLen = Math.max(1, width - titleLen - 3);
|
|
339
|
+
lines.push(
|
|
340
|
+
theme.fg("border", "╭─") +
|
|
341
|
+
theme.fg("accent", theme.bold(titleText)) +
|
|
342
|
+
theme.fg("border", "─".repeat(topRuleLen)) +
|
|
343
|
+
theme.fg("border", "╮"),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
// Scope tabs
|
|
347
|
+
const tabs = renderTabs(contentWidth);
|
|
348
|
+
if (tabs) {
|
|
349
|
+
lines.push(padLine(tabs, contentWidth));
|
|
350
|
+
}
|
|
351
|
+
lines.push(padLine("", contentWidth));
|
|
352
|
+
|
|
353
|
+
// Settings content
|
|
354
|
+
const innerLines = settings?.render(contentWidth) ?? [];
|
|
355
|
+
for (const line of innerLines) {
|
|
356
|
+
lines.push(padLine(line, contentWidth));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Separator
|
|
360
|
+
lines.push(
|
|
361
|
+
theme.fg("border", "├") +
|
|
362
|
+
theme.fg("border", "─".repeat(contentWidth)) +
|
|
363
|
+
theme.fg("border", "┤"),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// Controls
|
|
367
|
+
const parts = ["Enter/Space change"];
|
|
368
|
+
if (enabledScopes.length > 1) {
|
|
369
|
+
parts.push("Tab/Shift+Tab scope");
|
|
370
|
+
}
|
|
371
|
+
parts.push("Ctrl+S save", "Esc close");
|
|
372
|
+
const controlsText = theme.fg("dim", ` ${parts.join(" · ")}`);
|
|
373
|
+
lines.push(padLine(controlsText, contentWidth));
|
|
374
|
+
|
|
375
|
+
// Bottom border
|
|
376
|
+
lines.push(
|
|
377
|
+
theme.fg("border", "╰") +
|
|
378
|
+
theme.fg("border", "─".repeat(contentWidth)) +
|
|
379
|
+
theme.fg("border", "╯"),
|
|
380
|
+
);
|
|
381
|
+
|
|
320
382
|
return lines;
|
|
321
383
|
},
|
|
322
384
|
invalidate() {
|