@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 +152 -0
- package/components/array-editor.ts +213 -0
- package/components/sectioned-settings.ts +379 -0
- package/config-loader.ts +212 -0
- package/helpers.ts +61 -0
- package/index.ts +34 -0
- package/package.json +27 -0
- package/settings-command.ts +285 -0
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
|
+
}
|
package/config-loader.ts
ADDED
|
@@ -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
|
+
}
|