@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.
@@ -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.4.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
  }
@@ -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 { Key, matchesKey } from "@mariozechner/pi-tui";
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, hintSuffix: "Ctrl+S to save" },
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 ["", ` ${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
+ );
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
- lines.push(theme.fg("accent", theme.bold(title)));
318
- lines.push(...renderTabs());
319
- 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
+
320
382
  return lines;
321
383
  },
322
384
  invalidate() {