@aliou/pi-utils-settings 0.0.1

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/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # @aliou/pi-utils-settings
2
+
3
+ Shared settings infrastructure for [pi](https://github.com/mariozechner/pi-coding-agent) extensions. Provides config loading, a settings UI command with Local/Global tabs, and reusable TUI components.
4
+
5
+ This is a utility library, not a pi extension. It is meant to be used as a dependency by extensions that need a settings UI or JSON config management.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @aliou/pi-utils-settings
11
+ ```
12
+
13
+ ## API
14
+
15
+ ### ConfigLoader
16
+
17
+ Generic JSON config loader with global + project scopes, deep merge, and versioned migrations.
18
+
19
+ ```typescript
20
+ import { ConfigLoader, type Migration } from "@aliou/pi-utils-settings";
21
+
22
+ interface MyConfig {
23
+ features?: { darkMode?: boolean };
24
+ }
25
+
26
+ interface ResolvedConfig {
27
+ features: { darkMode: boolean };
28
+ }
29
+
30
+ const migrations: Migration<MyConfig>[] = [
31
+ {
32
+ name: "v1-upgrade",
33
+ shouldRun: (config) => !config.features,
34
+ run: (config) => ({ ...config, features: {} }),
35
+ },
36
+ ];
37
+
38
+ const configLoader = new ConfigLoader<MyConfig, ResolvedConfig>(
39
+ "my-extension", // reads ~/.pi/agent/extensions/my-extension.json + .pi/extensions/my-extension.json
40
+ { features: { darkMode: false } }, // defaults
41
+ { migrations },
42
+ );
43
+
44
+ await configLoader.load();
45
+ const config = configLoader.getConfig(); // ResolvedConfig (defaults merged with global + project)
46
+ ```
47
+
48
+ An optional `afterMerge` hook runs after the deep merge for logic that can't be expressed as a simple merge (e.g., one field replacing another):
49
+
50
+ ```typescript
51
+ new ConfigLoader("my-ext", defaults, {
52
+ afterMerge: (resolved, global, project) => {
53
+ if (project?.customField) {
54
+ resolved.derivedField = project.customField;
55
+ }
56
+ return resolved;
57
+ },
58
+ });
59
+ ```
60
+
61
+ ### registerSettingsCommand
62
+
63
+ Creates a `/name:settings` command with Local/Global tabs, draft-based editing, and Ctrl+S to save.
64
+
65
+ All changes (boolean toggles, enum cycling, submenu edits) are held in memory as drafts. Nothing is written to disk until the user presses Ctrl+S. Esc exits without saving. Dirty tabs show a `*` marker.
66
+
67
+ ```typescript
68
+ import { registerSettingsCommand, type SettingsSection } from "@aliou/pi-utils-settings";
69
+
70
+ registerSettingsCommand<MyConfig, ResolvedConfig>(pi, {
71
+ commandName: "my-ext:settings",
72
+ title: "My Extension Settings",
73
+ configStore: configLoader, // implements ConfigStore interface
74
+ buildSections: (tabConfig, resolved, { setDraft }) => [
75
+ {
76
+ label: "General",
77
+ items: [
78
+ {
79
+ id: "features.darkMode",
80
+ label: "Dark mode",
81
+ description: "Enable dark mode",
82
+ currentValue: (tabConfig?.features?.darkMode ?? resolved.features.darkMode) ? "on" : "off",
83
+ values: ["on", "off"],
84
+ },
85
+ ],
86
+ },
87
+ ],
88
+ });
89
+ ```
90
+
91
+ ### Submenu support
92
+
93
+ Items can open submenus by providing a `submenu` factory. Use `setDraft` inside submenu `onSave` to keep changes in the draft (same save model as simple values):
94
+
95
+ ```typescript
96
+ import { ArrayEditor, setNestedValue } from "@aliou/pi-utils-settings";
97
+
98
+ {
99
+ id: "tags",
100
+ label: "Tags",
101
+ currentValue: `${tags.length} items`,
102
+ submenu: (_val, done) => {
103
+ let latest = [...tags];
104
+ return new ArrayEditor({
105
+ label: "Tags",
106
+ items: [...tags],
107
+ theme: getSettingsListTheme(),
108
+ onSave: (items) => {
109
+ latest = items;
110
+ const updated = structuredClone(tabConfig ?? {}) as MyConfig;
111
+ setNestedValue(updated, "tags", items);
112
+ setDraft(updated);
113
+ },
114
+ onDone: () => done(`${latest.length} items`),
115
+ });
116
+ },
117
+ }
118
+ ```
119
+
120
+ ### ConfigStore interface
121
+
122
+ Extensions with custom config loaders can implement `ConfigStore` directly instead of using `ConfigLoader`:
123
+
124
+ ```typescript
125
+ interface ConfigStore<TConfig, TResolved> {
126
+ getConfig(): TResolved;
127
+ getRawConfig(scope: "global" | "project"): TConfig | null;
128
+ hasConfig(scope: "global" | "project"): boolean;
129
+ save(scope: "global" | "project", config: TConfig): Promise<void>;
130
+ }
131
+ ```
132
+
133
+ ### Components
134
+
135
+ - **SectionedSettings**: Grouped settings list with search filtering and cursor preservation on update.
136
+ - **ArrayEditor**: String array editor with add/remove/reorder.
137
+
138
+ ### Helpers
139
+
140
+ - `setNestedValue(obj, "a.b.c", value)`: Set a deeply nested value by dot-separated path.
141
+ - `getNestedValue(obj, "a.b.c")`: Get a deeply nested value by dot-separated path.
142
+ - `displayToStorageValue(id, displayValue)`: Convert display values (`"enabled"/"disabled"`, `"on"/"off"`) to storage values (`true/false`).
143
+
144
+ ## Exports
145
+
146
+ ```typescript
147
+ export { ConfigLoader, type ConfigStore, type Migration } from "./config-loader";
148
+ export { registerSettingsCommand, type SettingsCommandOptions } from "./settings-command";
149
+ export { SectionedSettings, type SectionedSettingsOptions, type SettingsSection } from "./components/sectioned-settings";
150
+ export { ArrayEditor, type ArrayEditorOptions } from "./components/array-editor";
151
+ export { setNestedValue, getNestedValue, displayToStorageValue } from "./helpers";
152
+ ```
@@ -0,0 +1,213 @@
1
+ import type { Component, SettingsListTheme } from "@mariozechner/pi-tui";
2
+ import {
3
+ Input,
4
+ Key,
5
+ matchesKey,
6
+ truncateToWidth,
7
+ visibleWidth,
8
+ } from "@mariozechner/pi-tui";
9
+
10
+ /**
11
+ * A submenu component for editing string arrays inside a SettingsList.
12
+ *
13
+ * Modes:
14
+ * - list: navigate items, delete with 'd', add with 'a', edit with 'e'/Enter
15
+ * - add: text input for new item, confirm with Enter, cancel with Escape
16
+ * - edit: text input pre-filled with current value, Enter saves, Escape cancels
17
+ */
18
+
19
+ export interface ArrayEditorOptions {
20
+ label: string;
21
+ items: string[];
22
+ theme: SettingsListTheme;
23
+ onSave: (items: string[]) => void;
24
+ onDone: () => void;
25
+ /** Max visible items before scrolling */
26
+ maxVisible?: number;
27
+ }
28
+
29
+ export class ArrayEditor implements Component {
30
+ private items: string[];
31
+ private label: string;
32
+ private theme: SettingsListTheme;
33
+ private onSave: (items: string[]) => void;
34
+ private onDone: () => void;
35
+ private selectedIndex = 0;
36
+ private maxVisible: number;
37
+ private mode: "list" | "add" | "edit" = "list";
38
+ private input: Input;
39
+ private editIndex = -1;
40
+
41
+ constructor(options: ArrayEditorOptions) {
42
+ this.items = [...options.items];
43
+ this.label = options.label;
44
+ this.theme = options.theme;
45
+ this.onSave = options.onSave;
46
+ this.onDone = options.onDone;
47
+ this.maxVisible = options.maxVisible ?? 10;
48
+ this.input = new Input();
49
+ this.input.onSubmit = (value: string) => {
50
+ if (this.mode === "edit") {
51
+ this.submitEdit(value);
52
+ } else {
53
+ this.submitAdd(value);
54
+ }
55
+ };
56
+ this.input.onEscape = () => {
57
+ this.mode = "list";
58
+ this.editIndex = -1;
59
+ };
60
+ }
61
+
62
+ private submitAdd(value: string) {
63
+ const trimmed = value.trim();
64
+ if (!trimmed) {
65
+ this.mode = "list";
66
+ return;
67
+ }
68
+ this.items.push(trimmed);
69
+ this.selectedIndex = this.items.length - 1;
70
+ this.save();
71
+ this.mode = "list";
72
+ this.input.setValue("");
73
+ }
74
+
75
+ private submitEdit(value: string) {
76
+ const trimmed = value.trim();
77
+ if (!trimmed) {
78
+ // Empty value = cancel edit
79
+ this.mode = "list";
80
+ this.editIndex = -1;
81
+ return;
82
+ }
83
+ this.items[this.editIndex] = trimmed;
84
+ this.save();
85
+ this.mode = "list";
86
+ this.editIndex = -1;
87
+ this.input.setValue("");
88
+ }
89
+
90
+ private deleteSelected() {
91
+ if (this.items.length === 0) return;
92
+ this.items.splice(this.selectedIndex, 1);
93
+ if (this.selectedIndex >= this.items.length) {
94
+ this.selectedIndex = Math.max(0, this.items.length - 1);
95
+ }
96
+ this.save();
97
+ }
98
+
99
+ private startEdit() {
100
+ if (this.items.length === 0) return;
101
+ this.editIndex = this.selectedIndex;
102
+ this.mode = "edit";
103
+ this.input.setValue(this.items[this.selectedIndex] as string);
104
+ }
105
+
106
+ private save() {
107
+ this.onSave([...this.items]);
108
+ }
109
+
110
+ invalidate() {}
111
+
112
+ render(width: number): string[] {
113
+ const lines: string[] = [];
114
+
115
+ // Header
116
+ lines.push(this.theme.label(` ${this.label}`, true));
117
+ lines.push("");
118
+
119
+ if (this.mode === "add" || this.mode === "edit") {
120
+ return [...lines, ...this.renderInputMode(width)];
121
+ }
122
+
123
+ return [...lines, ...this.renderListMode(width)];
124
+ }
125
+
126
+ private renderListMode(width: number): string[] {
127
+ const lines: string[] = [];
128
+
129
+ if (this.items.length === 0) {
130
+ lines.push(this.theme.hint(" (empty)"));
131
+ } else {
132
+ const startIndex = Math.max(
133
+ 0,
134
+ Math.min(
135
+ this.selectedIndex - Math.floor(this.maxVisible / 2),
136
+ this.items.length - this.maxVisible,
137
+ ),
138
+ );
139
+ const endIndex = Math.min(
140
+ startIndex + this.maxVisible,
141
+ this.items.length,
142
+ );
143
+
144
+ for (let i = startIndex; i < endIndex; i++) {
145
+ const item = this.items[i];
146
+ if (!item) continue;
147
+ const isSelected = i === this.selectedIndex;
148
+ const prefix = isSelected ? this.theme.cursor : " ";
149
+ const prefixWidth = visibleWidth(prefix);
150
+ const maxItemWidth = width - prefixWidth - 2;
151
+ const text = this.theme.value(
152
+ truncateToWidth(item, maxItemWidth, ""),
153
+ isSelected,
154
+ );
155
+ lines.push(prefix + text);
156
+ }
157
+
158
+ if (startIndex > 0 || endIndex < this.items.length) {
159
+ lines.push(
160
+ this.theme.hint(` (${this.selectedIndex + 1}/${this.items.length})`),
161
+ );
162
+ }
163
+ }
164
+
165
+ lines.push("");
166
+ lines.push(
167
+ this.theme.hint(" a: add · e/Enter: edit · d: delete · Esc: back"),
168
+ );
169
+
170
+ return lines;
171
+ }
172
+
173
+ private renderInputMode(width: number): string[] {
174
+ const lines: string[] = [];
175
+ const label = this.mode === "edit" ? " Edit item:" : " New item:";
176
+ lines.push(this.theme.hint(label));
177
+ lines.push(` ${this.input.render(width - 4).join("")}`);
178
+ lines.push("");
179
+ lines.push(this.theme.hint(" Enter: confirm · Esc: cancel"));
180
+ return lines;
181
+ }
182
+
183
+ handleInput(data: string) {
184
+ if (this.mode === "add" || this.mode === "edit") {
185
+ this.input.handleInput(data);
186
+ return;
187
+ }
188
+
189
+ // List mode
190
+ if (matchesKey(data, Key.up) || data === "k") {
191
+ if (this.items.length === 0) return;
192
+ this.selectedIndex =
193
+ this.selectedIndex === 0
194
+ ? this.items.length - 1
195
+ : this.selectedIndex - 1;
196
+ } else if (matchesKey(data, Key.down) || data === "j") {
197
+ if (this.items.length === 0) return;
198
+ this.selectedIndex =
199
+ this.selectedIndex === this.items.length - 1
200
+ ? 0
201
+ : this.selectedIndex + 1;
202
+ } else if (data === "a" || data === "A") {
203
+ this.mode = "add";
204
+ this.input.setValue("");
205
+ } else if (data === "e" || data === "E" || matchesKey(data, Key.enter)) {
206
+ this.startEdit();
207
+ } else if (data === "d" || data === "D") {
208
+ this.deleteSelected();
209
+ } else if (matchesKey(data, Key.escape)) {
210
+ this.onDone();
211
+ }
212
+ }
213
+ }
@@ -0,0 +1,379 @@
1
+ import type { Component } from "@mariozechner/pi-tui";
2
+ import {
3
+ Input,
4
+ Key,
5
+ matchesKey,
6
+ type SettingItem,
7
+ type SettingsListTheme,
8
+ truncateToWidth,
9
+ visibleWidth,
10
+ wrapTextWithAnsi,
11
+ } from "@mariozechner/pi-tui";
12
+
13
+ /**
14
+ * A sectioned settings list. Items are grouped under section headers.
15
+ * Cursor skips section headers and only lands on items.
16
+ *
17
+ * Supports the same SettingItem interface as pi-tui's SettingsList,
18
+ * including value cycling and submenus.
19
+ */
20
+
21
+ export interface SettingsSection {
22
+ label: string;
23
+ items: SettingItem[];
24
+ }
25
+
26
+ export interface SectionedSettingsOptions {
27
+ enableSearch?: boolean;
28
+ /** Extra text appended to the hint line (e.g. "Ctrl+S to save"). */
29
+ hintSuffix?: string;
30
+ }
31
+
32
+ interface FlatEntry {
33
+ type: "section" | "item";
34
+ sectionLabel?: string;
35
+ item?: SettingItem;
36
+ }
37
+
38
+ export class SectionedSettings implements Component {
39
+ private sections: SettingsSection[];
40
+ private flatEntries: FlatEntry[];
41
+ private filteredEntries: FlatEntry[];
42
+ private theme: SettingsListTheme;
43
+ private selectedIndex: number; // index into selectable items only
44
+ private maxVisible: number;
45
+ private onChange: (id: string, newValue: string) => void;
46
+ private onCancel: () => void;
47
+ private searchInput?: Input;
48
+ private searchEnabled: boolean;
49
+ private hintSuffix: string;
50
+ private submenuComponent: Component | null = null;
51
+ private submenuItemIndex: number | null = null;
52
+
53
+ constructor(
54
+ sections: SettingsSection[],
55
+ maxVisible: number,
56
+ theme: SettingsListTheme,
57
+ onChange: (id: string, newValue: string) => void,
58
+ onCancel: () => void,
59
+ options: SectionedSettingsOptions = {},
60
+ ) {
61
+ this.sections = sections;
62
+ this.maxVisible = maxVisible;
63
+ this.theme = theme;
64
+ this.onChange = onChange;
65
+ this.onCancel = onCancel;
66
+ this.searchEnabled = options.enableSearch ?? false;
67
+ this.hintSuffix = options.hintSuffix ?? "";
68
+ this.selectedIndex = 0;
69
+
70
+ if (this.searchEnabled) {
71
+ this.searchInput = new Input();
72
+ }
73
+
74
+ this.flatEntries = this.buildFlatEntries(sections);
75
+ this.filteredEntries = this.flatEntries;
76
+ }
77
+
78
+ private buildFlatEntries(sections: SettingsSection[]): FlatEntry[] {
79
+ const entries: FlatEntry[] = [];
80
+ for (const section of sections) {
81
+ entries.push({ type: "section", sectionLabel: section.label });
82
+ for (const item of section.items) {
83
+ entries.push({ type: "item", item });
84
+ }
85
+ }
86
+ return entries;
87
+ }
88
+
89
+ private getSelectableItems(): SettingItem[] {
90
+ return this.filteredEntries
91
+ .filter((e) => e.type === "item" && e.item)
92
+ .map((e) => e.item as SettingItem);
93
+ }
94
+
95
+ /**
96
+ * Replace all sections while preserving the cursor position.
97
+ * The cursor is restored by matching the previously selected item's ID.
98
+ * If the item no longer exists, the index is clamped to the valid range.
99
+ *
100
+ * Use this instead of creating a new SectionedSettings instance when
101
+ * only values or item counts change (e.g., after saving a setting).
102
+ */
103
+ updateSections(sections: SettingsSection[]): void {
104
+ const currentId = this.getSelectableItems()[this.selectedIndex]?.id;
105
+
106
+ this.sections = sections;
107
+ this.flatEntries = this.buildFlatEntries(sections);
108
+ this.filterEntries(this.searchInput?.getValue() ?? "");
109
+
110
+ // Restore cursor by item ID.
111
+ if (currentId) {
112
+ const items = this.getSelectableItems();
113
+ const idx = items.findIndex((i) => i.id === currentId);
114
+ if (idx >= 0) {
115
+ this.selectedIndex = idx;
116
+ return;
117
+ }
118
+ }
119
+
120
+ // Fallback: clamp to valid range.
121
+ const count = this.getSelectableItems().length;
122
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, count - 1));
123
+ }
124
+
125
+ updateValue(id: string, newValue: string): void {
126
+ for (const section of this.sections) {
127
+ const item = section.items.find((i) => i.id === id);
128
+ if (item) {
129
+ item.currentValue = newValue;
130
+ return;
131
+ }
132
+ }
133
+ }
134
+
135
+ /** Returns true when a submenu is open (caller should not intercept input). */
136
+ hasActiveSubmenu(): boolean {
137
+ return this.submenuComponent !== null;
138
+ }
139
+
140
+ invalidate(): void {
141
+ this.submenuComponent?.invalidate?.();
142
+ }
143
+
144
+ render(width: number): string[] {
145
+ if (this.submenuComponent) {
146
+ return this.submenuComponent.render(width);
147
+ }
148
+ return this.renderMainList(width);
149
+ }
150
+
151
+ private renderMainList(width: number): string[] {
152
+ const lines: string[] = [];
153
+
154
+ if (this.searchEnabled && this.searchInput) {
155
+ lines.push(...this.searchInput.render(width));
156
+ lines.push("");
157
+ }
158
+
159
+ const allItems = this.getSelectableItems();
160
+
161
+ if (allItems.length === 0) {
162
+ lines.push(
163
+ this.theme.hint(
164
+ this.searchEnabled
165
+ ? " No matching settings"
166
+ : " No settings available",
167
+ ),
168
+ );
169
+ this.addHintLine(lines);
170
+ return lines;
171
+ }
172
+
173
+ // Calculate max label width for alignment
174
+ const maxLabelWidth = Math.min(
175
+ 30,
176
+ Math.max(...allItems.map((item) => visibleWidth(item.label))),
177
+ );
178
+
179
+ // Build visible entries with their "selectable index"
180
+ let selectableIdx = -1;
181
+ const rendered: Array<{
182
+ line: string;
183
+ isSelected: boolean;
184
+ description?: string;
185
+ }> = [];
186
+
187
+ for (const entry of this.filteredEntries) {
188
+ if (entry.type === "section") {
189
+ // Section header - add blank line before (except first)
190
+ if (rendered.length > 0) {
191
+ rendered.push({ line: "", isSelected: false });
192
+ }
193
+ rendered.push({
194
+ line: this.theme.hint(` ${entry.sectionLabel}`),
195
+ isSelected: false,
196
+ });
197
+ continue;
198
+ }
199
+
200
+ const item = entry.item;
201
+ if (!item) continue;
202
+
203
+ selectableIdx++;
204
+ const isSelected = selectableIdx === this.selectedIndex;
205
+ const prefix = isSelected ? this.theme.cursor : " ";
206
+ const prefixWidth = visibleWidth(prefix);
207
+
208
+ const labelPadded =
209
+ item.label +
210
+ " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
211
+ const labelText = this.theme.label(labelPadded, isSelected);
212
+
213
+ const separator = " ";
214
+ const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
215
+ const valueMaxWidth = width - usedWidth - 2;
216
+ const valueText = this.theme.value(
217
+ truncateToWidth(item.currentValue, valueMaxWidth, ""),
218
+ isSelected,
219
+ );
220
+
221
+ rendered.push({
222
+ line: prefix + labelText + separator + valueText,
223
+ isSelected,
224
+ description: isSelected ? item.description : undefined,
225
+ });
226
+ }
227
+
228
+ // Scrolling: find the rendered index of the selected item
229
+ const selectedRenderedIdx = rendered.findIndex((r) => r.isSelected);
230
+ const totalLines = rendered.length;
231
+ const startLine = Math.max(
232
+ 0,
233
+ Math.min(
234
+ selectedRenderedIdx - Math.floor(this.maxVisible / 2),
235
+ totalLines - this.maxVisible,
236
+ ),
237
+ );
238
+ const endLine = Math.min(startLine + this.maxVisible, totalLines);
239
+
240
+ for (let i = startLine; i < endLine; i++) {
241
+ const r = rendered[i];
242
+ if (r) lines.push(r.line);
243
+ }
244
+
245
+ // Scroll indicator
246
+ if (startLine > 0 || endLine < totalLines) {
247
+ lines.push(
248
+ this.theme.hint(` (${this.selectedIndex + 1}/${allItems.length})`),
249
+ );
250
+ }
251
+
252
+ // Description for selected item
253
+ const selectedItem = allItems[this.selectedIndex];
254
+ if (selectedItem?.description) {
255
+ lines.push("");
256
+ const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
257
+ for (const line of wrappedDesc) {
258
+ lines.push(this.theme.description(` ${line}`));
259
+ }
260
+ }
261
+
262
+ this.addHintLine(lines);
263
+ return lines;
264
+ }
265
+
266
+ handleInput(data: string): void {
267
+ if (this.submenuComponent) {
268
+ this.submenuComponent.handleInput?.(data);
269
+ return;
270
+ }
271
+
272
+ const items = this.getSelectableItems();
273
+
274
+ if (matchesKey(data, Key.up)) {
275
+ if (items.length === 0) return;
276
+ this.selectedIndex =
277
+ this.selectedIndex === 0 ? items.length - 1 : this.selectedIndex - 1;
278
+ } else if (matchesKey(data, Key.down)) {
279
+ if (items.length === 0) return;
280
+ this.selectedIndex =
281
+ this.selectedIndex === items.length - 1 ? 0 : this.selectedIndex + 1;
282
+ } else if (matchesKey(data, Key.enter) || data === " ") {
283
+ this.activateItem();
284
+ } else if (matchesKey(data, Key.escape)) {
285
+ this.onCancel();
286
+ } else if (this.searchEnabled && this.searchInput) {
287
+ const sanitized = data.replace(/ /g, "");
288
+ if (!sanitized) return;
289
+ this.searchInput.handleInput(sanitized);
290
+ this.applyFilter(this.searchInput.getValue());
291
+ }
292
+ }
293
+
294
+ private activateItem(): void {
295
+ const items = this.getSelectableItems();
296
+ const item = items[this.selectedIndex];
297
+ if (!item) return;
298
+
299
+ if (item.submenu) {
300
+ this.submenuItemIndex = this.selectedIndex;
301
+ this.submenuComponent = item.submenu(
302
+ item.currentValue,
303
+ (selectedValue) => {
304
+ if (selectedValue !== undefined) {
305
+ item.currentValue = selectedValue;
306
+ this.onChange(item.id, selectedValue);
307
+ }
308
+ this.closeSubmenu();
309
+ },
310
+ );
311
+ } else if (item.values && item.values.length > 0) {
312
+ const currentIndex = item.values.indexOf(item.currentValue);
313
+ const nextIndex = (currentIndex + 1) % item.values.length;
314
+ const newValue = item.values[nextIndex] as string;
315
+ item.currentValue = newValue;
316
+ this.onChange(item.id, newValue);
317
+ }
318
+ }
319
+
320
+ private closeSubmenu(): void {
321
+ this.submenuComponent = null;
322
+ if (this.submenuItemIndex !== null) {
323
+ this.selectedIndex = this.submenuItemIndex;
324
+ this.submenuItemIndex = null;
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Apply search filter to entries without resetting the cursor.
330
+ * Used by updateSections() to preserve selection.
331
+ */
332
+ private filterEntries(query: string): void {
333
+ if (!query) {
334
+ this.filteredEntries = this.flatEntries;
335
+ return;
336
+ }
337
+
338
+ const filtered: FlatEntry[] = [];
339
+ let currentSection: FlatEntry | null = null;
340
+ let sectionHasMatch = false;
341
+
342
+ for (const entry of this.flatEntries) {
343
+ if (entry.type === "section") {
344
+ currentSection = entry;
345
+ sectionHasMatch = false;
346
+ continue;
347
+ }
348
+
349
+ if (entry.item) {
350
+ const label = entry.item.label.toLowerCase();
351
+ const q = query.toLowerCase();
352
+ if (label.includes(q)) {
353
+ if (currentSection && !sectionHasMatch) {
354
+ filtered.push(currentSection);
355
+ sectionHasMatch = true;
356
+ }
357
+ filtered.push(entry);
358
+ }
359
+ }
360
+ }
361
+
362
+ this.filteredEntries = filtered;
363
+ }
364
+
365
+ /** Apply search filter and reset cursor to the first item. */
366
+ private applyFilter(query: string): void {
367
+ this.filterEntries(query);
368
+ this.selectedIndex = 0;
369
+ }
370
+
371
+ private addHintLine(lines: string[]): void {
372
+ const suffix = this.hintSuffix ? ` \u00B7 ${this.hintSuffix}` : "";
373
+ const base = this.searchEnabled
374
+ ? " Type to search \u00B7 Enter/Space to change \u00B7 Esc to close"
375
+ : " Enter/Space to change \u00B7 Esc to close";
376
+ lines.push("");
377
+ lines.push(this.theme.hint(base + suffix));
378
+ }
379
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Generic JSON config loader for pi extensions.
3
+ *
4
+ * Loads config from two files (global + project), deep-merges with defaults,
5
+ * and optionally applies versioned migrations.
6
+ *
7
+ * Global: ~/.pi/agent/extensions/{name}.json
8
+ * Project: .pi/extensions/{name}.json
9
+ */
10
+
11
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
12
+ import { dirname, resolve } from "node:path";
13
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
14
+
15
+ /**
16
+ * A migration that transforms a config from one version to another.
17
+ * Migrations are applied in order during load(). If any migration
18
+ * returns a modified config, the result is saved back to disk.
19
+ */
20
+ export interface Migration<TConfig> {
21
+ /** Name for logging on failure. */
22
+ name: string;
23
+ /** Return true if this migration should run on the given config. */
24
+ shouldRun: (config: TConfig) => boolean;
25
+ /**
26
+ * Transform the config. Receives the file path for backup/logging.
27
+ * Return the migrated config.
28
+ */
29
+ run: (config: TConfig, filePath: string) => Promise<TConfig> | TConfig;
30
+ }
31
+
32
+ /**
33
+ * Interface for settings storage, used by registerSettingsCommand.
34
+ * ConfigLoader implements this. Extensions with custom loaders can
35
+ * implement this interface directly.
36
+ */
37
+ export interface ConfigStore<TConfig extends object, TResolved extends object> {
38
+ getConfig(): TResolved;
39
+ getRawConfig(scope: "global" | "project"): TConfig | null;
40
+ hasConfig(scope: "global" | "project"): boolean;
41
+ save(scope: "global" | "project", config: TConfig): Promise<void>;
42
+ }
43
+
44
+ export class ConfigLoader<TConfig extends object, TResolved extends object>
45
+ implements ConfigStore<TConfig, TResolved>
46
+ {
47
+ private globalConfig: TConfig | null = null;
48
+ private projectConfig: TConfig | null = null;
49
+ private resolved: TResolved | null = null;
50
+
51
+ private readonly globalPath: string;
52
+ private readonly projectPath: string;
53
+ private readonly defaults: TResolved;
54
+ private readonly migrations: Migration<TConfig>[];
55
+ private readonly afterMerge?: (
56
+ resolved: TResolved,
57
+ global: TConfig | null,
58
+ project: TConfig | null,
59
+ ) => TResolved;
60
+
61
+ constructor(
62
+ extensionName: string,
63
+ defaults: TResolved,
64
+ options?: {
65
+ migrations?: Migration<TConfig>[];
66
+ /**
67
+ * Post-merge hook. Called after deep merge with both raw configs.
68
+ * Use for logic that can't be expressed as a simple merge
69
+ * (e.g., one field replacing another).
70
+ */
71
+ afterMerge?: (
72
+ resolved: TResolved,
73
+ global: TConfig | null,
74
+ project: TConfig | null,
75
+ ) => TResolved;
76
+ },
77
+ ) {
78
+ this.globalPath = resolve(
79
+ getAgentDir(),
80
+ `extensions/${extensionName}.json`,
81
+ );
82
+ this.projectPath = resolve(
83
+ process.cwd(),
84
+ `.pi/extensions/${extensionName}.json`,
85
+ );
86
+ this.defaults = defaults;
87
+ this.migrations = options?.migrations ?? [];
88
+ this.afterMerge = options?.afterMerge;
89
+ }
90
+
91
+ /**
92
+ * Load (or reload) config from disk. Applies migrations if needed.
93
+ * Must be called before getConfig() or getRawConfig().
94
+ */
95
+ async load(): Promise<void> {
96
+ this.globalConfig = await this.readFile(this.globalPath);
97
+ this.projectConfig = await this.readFile(this.projectPath);
98
+
99
+ if (this.globalConfig) {
100
+ this.globalConfig = await this.applyMigrations(
101
+ this.globalConfig,
102
+ this.globalPath,
103
+ );
104
+ }
105
+ if (this.projectConfig) {
106
+ this.projectConfig = await this.applyMigrations(
107
+ this.projectConfig,
108
+ this.projectPath,
109
+ );
110
+ }
111
+
112
+ this.resolved = this.merge();
113
+ }
114
+
115
+ getConfig(): TResolved {
116
+ if (!this.resolved) {
117
+ throw new Error("Config not loaded. Call load() first.");
118
+ }
119
+ return this.resolved;
120
+ }
121
+
122
+ getRawConfig(scope: "global" | "project"): TConfig | null {
123
+ return scope === "global" ? this.globalConfig : this.projectConfig;
124
+ }
125
+
126
+ hasConfig(scope: "global" | "project"): boolean {
127
+ return scope === "global"
128
+ ? this.globalConfig !== null
129
+ : this.projectConfig !== null;
130
+ }
131
+
132
+ /** Save config and reload all state. */
133
+ async save(scope: "global" | "project", config: TConfig): Promise<void> {
134
+ const path = scope === "global" ? this.globalPath : this.projectPath;
135
+ await this.writeFile(path, config);
136
+ await this.load();
137
+ }
138
+
139
+ // --- Internal ---
140
+
141
+ private async applyMigrations(
142
+ config: TConfig,
143
+ filePath: string,
144
+ ): Promise<TConfig> {
145
+ let current = config;
146
+ let changed = false;
147
+
148
+ for (const migration of this.migrations) {
149
+ if (!migration.shouldRun(current)) continue;
150
+ try {
151
+ current = await migration.run(current, filePath);
152
+ changed = true;
153
+ } catch (error) {
154
+ console.error(
155
+ `[settings] Migration "${migration.name}" failed for ${filePath}: ${error}`,
156
+ );
157
+ }
158
+ }
159
+
160
+ if (changed) {
161
+ try {
162
+ await this.writeFile(filePath, current);
163
+ } catch {
164
+ // Save failed — use migrated version in memory only.
165
+ }
166
+ }
167
+
168
+ return current;
169
+ }
170
+
171
+ private merge(): TResolved {
172
+ const merged = structuredClone(this.defaults);
173
+ if (this.globalConfig) this.deepMerge(merged, this.globalConfig);
174
+ if (this.projectConfig) this.deepMerge(merged, this.projectConfig);
175
+ if (this.afterMerge) {
176
+ return this.afterMerge(merged, this.globalConfig, this.projectConfig);
177
+ }
178
+ return merged;
179
+ }
180
+
181
+ private deepMerge(target: object, source: object): void {
182
+ const t = target as Record<string, unknown>;
183
+ const s = source as Record<string, unknown>;
184
+ for (const key in s) {
185
+ if (s[key] === undefined) continue;
186
+ if (
187
+ typeof s[key] === "object" &&
188
+ !Array.isArray(s[key]) &&
189
+ s[key] !== null
190
+ ) {
191
+ if (!t[key] || typeof t[key] !== "object") t[key] = {};
192
+ this.deepMerge(t[key] as object, s[key] as object);
193
+ } else {
194
+ t[key] = s[key];
195
+ }
196
+ }
197
+ }
198
+
199
+ private async readFile(path: string): Promise<TConfig | null> {
200
+ try {
201
+ const content = await readFile(path, "utf-8");
202
+ return JSON.parse(content) as TConfig;
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ private async writeFile(path: string, config: TConfig): Promise<void> {
209
+ await mkdir(dirname(path), { recursive: true });
210
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
211
+ }
212
+ }
package/helpers.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Shared helpers for settings management.
3
+ */
4
+
5
+ /**
6
+ * Set a deeply nested value on an object using a dotted path.
7
+ * Creates intermediate objects as needed.
8
+ *
9
+ * Example: setNestedValue(obj, "features.debug", true)
10
+ * sets obj.features.debug = true, creating obj.features if needed.
11
+ */
12
+ export function setNestedValue(
13
+ obj: object,
14
+ path: string,
15
+ value: unknown,
16
+ ): void {
17
+ const parts = path.split(".");
18
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic path traversal
19
+ let target: any = obj;
20
+ for (let i = 0; i < parts.length - 1; i++) {
21
+ const key = parts[i] as string;
22
+ if (!target[key] || typeof target[key] !== "object") target[key] = {};
23
+ target = target[key];
24
+ }
25
+ target[parts[parts.length - 1] as string] = value;
26
+ }
27
+
28
+ /**
29
+ * Get a deeply nested value from an object using a dotted path.
30
+ * Returns undefined if any intermediate key is missing.
31
+ */
32
+ export function getNestedValue(obj: object, path: string): unknown {
33
+ const parts = path.split(".");
34
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic path traversal
35
+ let target: any = obj;
36
+ for (const part of parts) {
37
+ if (target == null) return undefined;
38
+ target = target[part];
39
+ }
40
+ return target;
41
+ }
42
+
43
+ /**
44
+ * Map a UI display value to its storage representation.
45
+ *
46
+ * "enabled" / "on" -> true
47
+ * "disabled" / "off" -> false
48
+ * anything else -> the string as-is (for enums like "pnpm")
49
+ */
50
+ export function displayToStorageValue(displayValue: string): unknown {
51
+ switch (displayValue) {
52
+ case "enabled":
53
+ case "on":
54
+ return true;
55
+ case "disabled":
56
+ case "off":
57
+ return false;
58
+ default:
59
+ return displayValue;
60
+ }
61
+ }
package/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @aliou/pi-utils-settings
3
+ *
4
+ * Shared settings infrastructure for pi extensions:
5
+ * - ConfigLoader: load/save/merge JSON configs from global + project paths
6
+ * - registerSettingsCommand: create a settings command with Local/Global tabs
7
+ * - SectionedSettings: sectioned settings list component
8
+ * - ArrayEditor: string array editor submenu component
9
+ * - Helpers: nested value access, display-to-storage value mapping
10
+ */
11
+
12
+ export {
13
+ ArrayEditor,
14
+ type ArrayEditorOptions,
15
+ } from "./components/array-editor";
16
+ export {
17
+ SectionedSettings,
18
+ type SectionedSettingsOptions,
19
+ type SettingsSection,
20
+ } from "./components/sectioned-settings";
21
+ export {
22
+ ConfigLoader,
23
+ type ConfigStore,
24
+ type Migration,
25
+ } from "./config-loader";
26
+ export {
27
+ displayToStorageValue,
28
+ getNestedValue,
29
+ setNestedValue,
30
+ } from "./helpers";
31
+ export {
32
+ registerSettingsCommand,
33
+ type SettingsCommandOptions,
34
+ } from "./settings-command";
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@aliou/pi-utils-settings",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "private": false,
6
+ "license": "MIT",
7
+ "description": "Shared settings UI and config loader for pi extensions",
8
+ "exports": {
9
+ ".": "./index.ts"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/aliou/pi-extensions",
14
+ "directory": "packages/settings"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "files": [
20
+ "*.ts",
21
+ "components",
22
+ "README.md"
23
+ ],
24
+ "peerDependencies": {
25
+ "@mariozechner/pi-coding-agent": ">=0.51.0"
26
+ }
27
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Settings command registration helper.
3
+ *
4
+ * Creates a /{name}:settings command with Local/Global tabs.
5
+ * Changes are tracked in memory. Ctrl+S saves, Esc exits without saving.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
10
+ import { Key, matchesKey } from "@mariozechner/pi-tui";
11
+ import {
12
+ SectionedSettings,
13
+ type SettingsSection,
14
+ } from "./components/sectioned-settings";
15
+ import type { ConfigStore } from "./config-loader";
16
+ import { displayToStorageValue, setNestedValue } from "./helpers";
17
+
18
+ type Tab = "local" | "global";
19
+
20
+ export interface SettingsCommandOptions<
21
+ TConfig extends object,
22
+ TResolved extends object,
23
+ > {
24
+ /** Command name, e.g. "toolchain:settings" */
25
+ commandName: string;
26
+ /** Command description for the command palette. */
27
+ commandDescription?: string;
28
+ /** Title shown at the top of the settings UI. */
29
+ title: string;
30
+ /** Config store (ConfigLoader or custom implementation). */
31
+ configStore: ConfigStore<TConfig, TResolved>;
32
+ /**
33
+ * Build the sections for the current tab.
34
+ * Called on initial render, tab switch, and after saving.
35
+ *
36
+ * Use ctx.setDraft in submenu onSave callbacks to store changes
37
+ * in the draft. All changes (toggles, enums, submenus) are only
38
+ * persisted to disk on Ctrl+S.
39
+ */
40
+ buildSections: (
41
+ tabConfig: TConfig | null,
42
+ resolved: TResolved,
43
+ ctx: { setDraft: (config: TConfig) => void },
44
+ ) => SettingsSection[];
45
+ /**
46
+ * Custom change handler. Receives the setting ID, new display value,
47
+ * and a clone of the current tab config. Return the updated config,
48
+ * or null to skip the change.
49
+ *
50
+ * If not provided, the default handler maps boolean display values
51
+ * (enabled/disabled, on/off) to true/false and sets via dotted path.
52
+ * Enum strings (e.g. "pnpm") are stored as-is.
53
+ */
54
+ onSettingChange?: (
55
+ id: string,
56
+ newValue: string,
57
+ config: TConfig,
58
+ ) => TConfig | null;
59
+ /**
60
+ * Called after save succeeds. Use this to reload runtime state
61
+ * that was captured at extension init time.
62
+ */
63
+ onSave?: () => void | Promise<void>;
64
+ }
65
+
66
+ function defaultChangeHandler<TConfig extends object>(
67
+ id: string,
68
+ newValue: string,
69
+ config: TConfig,
70
+ ): TConfig {
71
+ const updated = structuredClone(config);
72
+ setNestedValue(updated, id, displayToStorageValue(newValue));
73
+ return updated;
74
+ }
75
+
76
+ /**
77
+ * Find whether an item in the given sections has a submenu.
78
+ * Used to distinguish value cycling (track draft) from submenu close (refresh only).
79
+ */
80
+ function isSubmenuItem(sections: SettingsSection[], id: string): boolean {
81
+ for (const section of sections) {
82
+ for (const item of section.items) {
83
+ if (item.id === id && item.submenu) return true;
84
+ }
85
+ }
86
+ return false;
87
+ }
88
+
89
+ export function registerSettingsCommand<
90
+ TConfig extends object,
91
+ TResolved extends object,
92
+ >(pi: ExtensionAPI, options: SettingsCommandOptions<TConfig, TResolved>): void {
93
+ const {
94
+ commandName,
95
+ title,
96
+ configStore,
97
+ buildSections,
98
+ onSettingChange,
99
+ onSave,
100
+ } = options;
101
+ const description =
102
+ options.commandDescription ??
103
+ `Configure ${commandName.split(":")[0]} (local/global)`;
104
+ const extensionLabel = commandName.split(":")[0] ?? title;
105
+
106
+ pi.registerCommand(commandName, {
107
+ description,
108
+ handler: async (_args, ctx) => {
109
+ if (!ctx.hasUI) return;
110
+
111
+ let activeTab: Tab = configStore.hasConfig("project")
112
+ ? "local"
113
+ : "global";
114
+
115
+ await ctx.ui.custom((tui, theme, _kb, done) => {
116
+ let settings: SectionedSettings | null = null;
117
+ let currentSections: SettingsSection[] = [];
118
+ const settingsTheme = getSettingsListTheme();
119
+
120
+ // Per-tab draft configs. null = no changes from disk.
121
+ const drafts: Record<Tab, TConfig | null> = {
122
+ local: null,
123
+ global: null,
124
+ };
125
+
126
+ // --- Helpers ---
127
+
128
+ function tabScope(): "global" | "project" {
129
+ return activeTab === "local" ? "project" : "global";
130
+ }
131
+
132
+ /** Get the effective config for the active tab (draft or disk). */
133
+ function getTabConfig(): TConfig | null {
134
+ return drafts[activeTab] ?? configStore.getRawConfig(tabScope());
135
+ }
136
+
137
+ function isDirty(): boolean {
138
+ return drafts.local !== null || drafts.global !== null;
139
+ }
140
+
141
+ function getSections(): SettingsSection[] {
142
+ const tabConfig = getTabConfig();
143
+ const resolved = configStore.getConfig();
144
+ currentSections = buildSections(tabConfig, resolved, {
145
+ setDraft: (config) => {
146
+ drafts[activeTab] = config;
147
+ },
148
+ });
149
+ return currentSections;
150
+ }
151
+
152
+ function refresh(): void {
153
+ settings?.updateSections(getSections());
154
+ tui.requestRender();
155
+ }
156
+
157
+ function buildSettingsComponent(tab: Tab): SectionedSettings {
158
+ return new SectionedSettings(
159
+ getSections(),
160
+ 15,
161
+ settingsTheme,
162
+ (id, newValue) => {
163
+ handleChange(tab, id, newValue);
164
+ },
165
+ () => done(undefined),
166
+ { enableSearch: true, hintSuffix: "Ctrl+S to save" },
167
+ );
168
+ }
169
+
170
+ // --- Change handler (in-memory only) ---
171
+
172
+ function handleChange(tab: Tab, id: string, newValue: string): void {
173
+ // Submenu items handle their own saving.
174
+ if (isSubmenuItem(currentSections, id)) {
175
+ refresh();
176
+ return;
177
+ }
178
+
179
+ const current = getTabConfig();
180
+ const handler = onSettingChange ?? defaultChangeHandler;
181
+ const updated = handler(
182
+ id,
183
+ newValue,
184
+ structuredClone(current ?? ({} as TConfig)),
185
+ );
186
+ if (!updated) return;
187
+
188
+ // Store in draft, don't write to disk yet.
189
+ drafts[tab] = updated;
190
+ tui.requestRender();
191
+ }
192
+
193
+ // --- Save handler (Ctrl+S) ---
194
+
195
+ async function save(): Promise<void> {
196
+ let saved = false;
197
+
198
+ for (const tab of ["local", "global"] as const) {
199
+ const draft = drafts[tab];
200
+ if (!draft) continue;
201
+
202
+ const scope = tab === "local" ? "project" : "global";
203
+ try {
204
+ await configStore.save(scope, draft);
205
+ drafts[tab] = null;
206
+ saved = true;
207
+ } catch (error) {
208
+ ctx.ui.notify(`Failed to save ${tab}: ${error}`, "error");
209
+ }
210
+ }
211
+
212
+ if (saved) {
213
+ ctx.ui.notify(`${extensionLabel}: saved`, "info");
214
+ if (onSave) await onSave();
215
+ // Rebuild with fresh disk data.
216
+ settings = buildSettingsComponent(activeTab);
217
+ }
218
+
219
+ tui.requestRender();
220
+ }
221
+
222
+ // --- Tab rendering ---
223
+
224
+ function renderTabs(): string[] {
225
+ const dirtyMark = (tab: Tab) => (drafts[tab] ? " *" : "");
226
+
227
+ const localLabel =
228
+ activeTab === "local"
229
+ ? theme.bg(
230
+ "selectedBg",
231
+ theme.fg("accent", ` Local${dirtyMark("local")} `),
232
+ )
233
+ : theme.fg("dim", ` Local${dirtyMark("local")} `);
234
+ const globalLabel =
235
+ activeTab === "global"
236
+ ? theme.bg(
237
+ "selectedBg",
238
+ theme.fg("accent", ` Global${dirtyMark("global")} `),
239
+ )
240
+ : theme.fg("dim", ` Global${dirtyMark("global")} `);
241
+
242
+ return ["", ` ${localLabel} ${globalLabel}`, ""];
243
+ }
244
+
245
+ function handleTabSwitch(data: string): boolean {
246
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab"))) {
247
+ activeTab = activeTab === "local" ? "global" : "local";
248
+ settings = buildSettingsComponent(activeTab);
249
+ tui.requestRender();
250
+ return true;
251
+ }
252
+ return false;
253
+ }
254
+
255
+ // --- Init ---
256
+
257
+ settings = buildSettingsComponent(activeTab);
258
+
259
+ return {
260
+ render(width: number) {
261
+ const lines: string[] = [];
262
+ lines.push(theme.fg("accent", theme.bold(title)));
263
+ lines.push(...renderTabs());
264
+ lines.push(...(settings?.render(width) ?? []));
265
+ return lines;
266
+ },
267
+ invalidate() {
268
+ settings?.invalidate?.();
269
+ },
270
+ handleInput(data: string) {
271
+ // Ctrl+S: save all dirty tabs.
272
+ if (matchesKey(data, Key.ctrl("s"))) {
273
+ if (isDirty()) void save();
274
+ return;
275
+ }
276
+
277
+ if (!settings?.hasActiveSubmenu() && handleTabSwitch(data)) return;
278
+ settings?.handleInput?.(data);
279
+ tui.requestRender();
280
+ },
281
+ };
282
+ });
283
+ },
284
+ });
285
+ }