@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.
@@ -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.addHintLine(lines);
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.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
  }
@@ -5,9 +5,17 @@
5
5
  * Changes are tracked in memory. Ctrl+S saves, Esc exits without saving.
6
6
  */
7
7
 
8
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
+ import type {
9
+ ExtensionAPI,
10
+ ExtensionCommandContext,
11
+ } from "@mariozechner/pi-coding-agent";
9
12
  import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
10
- import { Key, matchesKey } from "@mariozechner/pi-tui";
13
+ import {
14
+ Key,
15
+ matchesKey,
16
+ truncateToWidth,
17
+ visibleWidth,
18
+ } from "@mariozechner/pi-tui";
11
19
  import {
12
20
  SectionedSettings,
13
21
  type SettingsSection,
@@ -76,7 +84,7 @@ export interface SettingsCommandOptions<
76
84
  * Called after save succeeds. Use this to reload runtime state
77
85
  * that was captured at extension init time.
78
86
  */
79
- onSave?: () => void | Promise<void>;
87
+ onSave?: (ctx: ExtensionCommandContext) => void | Promise<void>;
80
88
  }
81
89
 
82
90
  function defaultChangeHandler<TConfig extends object>(
@@ -197,7 +205,7 @@ export function registerSettingsCommand<
197
205
  handleChange(scope, id, newValue);
198
206
  },
199
207
  () => done(undefined),
200
- { enableSearch: true, hintSuffix: "Ctrl+S to save" },
208
+ { enableSearch: true, hideHint: true },
201
209
  );
202
210
  }
203
211
 
@@ -256,7 +264,7 @@ export function registerSettingsCommand<
256
264
 
257
265
  if (saved) {
258
266
  ctx.ui.notify(`${extensionLabel}: saved`, "info");
259
- if (onSave) await onSave();
267
+ if (onSave) await onSave(ctx);
260
268
  // Rebuild with fresh data.
261
269
  settings = buildSettingsComponent(activeScope);
262
270
  }
@@ -266,10 +274,10 @@ export function registerSettingsCommand<
266
274
 
267
275
  // --- Tab rendering ---
268
276
 
269
- function renderTabs(): string[] {
277
+ function renderTabs(_contentWidth: number): string {
270
278
  // Single scope = no tabs needed
271
279
  if (enabledScopes.length === 1) {
272
- return [""];
280
+ return "";
273
281
  }
274
282
 
275
283
  const tabLabels = enabledScopes.map((scope) => {
@@ -283,7 +291,18 @@ export function registerSettingsCommand<
283
291
  return theme.fg("dim", fullLabel);
284
292
  });
285
293
 
286
- return ["", ` ${tabLabels.join(" ")}`, ""];
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
+ );
287
306
  }
288
307
 
289
308
  function handleTabSwitch(data: string): boolean {
@@ -311,9 +330,55 @@ export function registerSettingsCommand<
311
330
  return {
312
331
  render(width: number) {
313
332
  const lines: string[] = [];
314
- lines.push(theme.fg("accent", theme.bold(title)));
315
- lines.push(...renderTabs());
316
- lines.push(...(settings?.render(width) ?? []));
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
+
317
382
  return lines;
318
383
  },
319
384
  invalidate() {