@juanibiapina/pi-extension-settings 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -19
- package/src/components/ordered-multi-select.ts +166 -0
- package/src/components/settings-list.ts +328 -0
- package/src/extension.ts +125 -0
- package/{dist/index.d.ts → src/index.ts} +5 -1
- package/src/settings/storage.ts +86 -0
- package/src/settings/types.ts +34 -0
- package/dist/components/ordered-multi-select.d.ts +0 -26
- package/dist/components/ordered-multi-select.d.ts.map +0 -1
- package/dist/components/ordered-multi-select.js +0 -141
- package/dist/components/ordered-multi-select.js.map +0 -1
- package/dist/components/settings-list.d.ts +0 -64
- package/dist/components/settings-list.d.ts.map +0 -1
- package/dist/components/settings-list.js +0 -251
- package/dist/components/settings-list.js.map +0 -1
- package/dist/extension.d.ts +0 -6
- package/dist/extension.d.ts.map +0 -1
- package/dist/extension.js +0 -96
- package/dist/extension.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -40
- package/dist/index.js.map +0 -1
- package/dist/settings/storage.d.ts +0 -23
- package/dist/settings/storage.d.ts.map +0 -1
- package/dist/settings/storage.js +0 -75
- package/dist/settings/storage.js.map +0 -1
- package/dist/settings/types.d.ts +0 -33
- package/dist/settings/types.d.ts.map +0 -1
- package/dist/settings/types.js +0 -5
- package/dist/settings/types.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juanibiapina/pi-extension-settings",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Pi extension for centralized settings management across extensions",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "dist/index.js",
|
|
7
|
-
"types": "./dist/index.d.ts",
|
|
8
6
|
"scripts": {
|
|
9
|
-
"clean": "rm -rf dist",
|
|
10
|
-
"build": "tsc -p tsconfig.build.json",
|
|
11
|
-
"dev": "tsc -p tsconfig.build.json --watch",
|
|
12
7
|
"check": "biome check --write --error-on-warnings . && tsc --noEmit",
|
|
13
|
-
"test": "node --test --import tsx test/*.test.ts"
|
|
14
|
-
"prepublishOnly": "npm run clean && npm run build && npm run check"
|
|
8
|
+
"test": "node --test --import tsx test/*.test.ts"
|
|
15
9
|
},
|
|
16
10
|
"files": [
|
|
17
|
-
"
|
|
11
|
+
"src/**/*",
|
|
18
12
|
"README.md"
|
|
19
13
|
],
|
|
20
14
|
"keywords": [
|
|
@@ -26,7 +20,7 @@
|
|
|
26
20
|
],
|
|
27
21
|
"pi": {
|
|
28
22
|
"extensions": [
|
|
29
|
-
"./
|
|
23
|
+
"./src/index.ts"
|
|
30
24
|
]
|
|
31
25
|
},
|
|
32
26
|
"author": "Juan Ibiapina",
|
|
@@ -38,15 +32,11 @@
|
|
|
38
32
|
"engines": {
|
|
39
33
|
"node": ">=20.0.0"
|
|
40
34
|
},
|
|
41
|
-
"peerDependencies": {
|
|
42
|
-
"@mariozechner/pi-coding-agent": "*",
|
|
43
|
-
"@mariozechner/pi-tui": "*"
|
|
44
|
-
},
|
|
45
35
|
"devDependencies": {
|
|
46
|
-
"@biomejs/biome": "2.4.
|
|
47
|
-
"@
|
|
48
|
-
"@types/node": "^25.
|
|
49
|
-
"tsx": "^4.
|
|
50
|
-
"typescript": "^6.0.
|
|
36
|
+
"@biomejs/biome": "2.4.15",
|
|
37
|
+
"@earendil-works/pi-coding-agent": "^0.75.3",
|
|
38
|
+
"@types/node": "^25.9.1",
|
|
39
|
+
"tsx": "^4.22.3",
|
|
40
|
+
"typescript": "^6.0.3"
|
|
51
41
|
}
|
|
52
42
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ordered multi-select component.
|
|
3
|
+
*
|
|
4
|
+
* Displays a list of options that can be toggled on/off and reordered.
|
|
5
|
+
* Selected items appear at the top with their position number.
|
|
6
|
+
* Value is stored as a comma-separated string of selected IDs in order.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type Component, getKeybindings, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import type { OrderedListOption } from "../settings/types.js";
|
|
11
|
+
import type { SettingsListTheme } from "./settings-list.js";
|
|
12
|
+
|
|
13
|
+
interface DisplayItem {
|
|
14
|
+
option: OrderedListOption;
|
|
15
|
+
selected: boolean;
|
|
16
|
+
/** 1-based position within selected items, or 0 if not selected */
|
|
17
|
+
position: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class OrderedMultiSelect implements Component {
|
|
21
|
+
private options: OrderedListOption[];
|
|
22
|
+
private selected: string[];
|
|
23
|
+
private cursorIndex = 0;
|
|
24
|
+
private theme: SettingsListTheme;
|
|
25
|
+
private done: (selectedValue?: string) => void;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
options: OrderedListOption[],
|
|
29
|
+
currentValue: string,
|
|
30
|
+
theme: SettingsListTheme,
|
|
31
|
+
done: (selectedValue?: string) => void,
|
|
32
|
+
) {
|
|
33
|
+
this.options = options;
|
|
34
|
+
this.selected = currentValue
|
|
35
|
+
.split(",")
|
|
36
|
+
.map((s) => s.trim())
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
this.theme = theme;
|
|
39
|
+
this.done = done;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
invalidate(): void {}
|
|
43
|
+
|
|
44
|
+
render(width: number): string[] {
|
|
45
|
+
const lines: string[] = [];
|
|
46
|
+
const items = this.buildDisplayItems();
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < items.length; i++) {
|
|
49
|
+
const item = items[i];
|
|
50
|
+
const isCursor = i === this.cursorIndex;
|
|
51
|
+
const prefix = isCursor ? this.theme.cursor : " ";
|
|
52
|
+
|
|
53
|
+
let marker: string;
|
|
54
|
+
if (item.selected) {
|
|
55
|
+
marker = this.theme.value(`✓ ${String(item.position).padStart(2)}`, isCursor);
|
|
56
|
+
} else {
|
|
57
|
+
marker = this.theme.label(" ", isCursor);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const label = this.theme.label(item.option.label, isCursor);
|
|
61
|
+
const line = `${prefix}${marker} ${label}`;
|
|
62
|
+
lines.push(truncateToWidth(line, width, "…"));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Scroll indicator
|
|
66
|
+
if (items.length > 0) {
|
|
67
|
+
const scrollText = ` (${this.cursorIndex + 1}/${items.length})`;
|
|
68
|
+
lines.push(this.theme.hint(scrollText));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Hint line
|
|
72
|
+
lines.push("");
|
|
73
|
+
lines.push(this.theme.hint(" Space toggle · Shift+↑/↓ reorder · Enter confirm · Esc cancel"));
|
|
74
|
+
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
handleInput(data: string): void {
|
|
79
|
+
const kb = getKeybindings();
|
|
80
|
+
const items = this.buildDisplayItems();
|
|
81
|
+
if (items.length === 0) {
|
|
82
|
+
if (kb.matches(data, "tui.select.cancel")) {
|
|
83
|
+
this.done(undefined);
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (kb.matches(data, "tui.select.up") || matchesKey(data, "up")) {
|
|
89
|
+
this.cursorIndex = this.cursorIndex === 0 ? items.length - 1 : this.cursorIndex - 1;
|
|
90
|
+
} else if (kb.matches(data, "tui.select.down") || matchesKey(data, "down")) {
|
|
91
|
+
this.cursorIndex = this.cursorIndex === items.length - 1 ? 0 : this.cursorIndex + 1;
|
|
92
|
+
} else if (data === " " || matchesKey(data, "space")) {
|
|
93
|
+
this.toggleCurrent(items);
|
|
94
|
+
} else if (matchesKey(data, "shift+up")) {
|
|
95
|
+
this.moveUp(items);
|
|
96
|
+
} else if (matchesKey(data, "shift+down")) {
|
|
97
|
+
this.moveDown(items);
|
|
98
|
+
} else if (kb.matches(data, "tui.select.confirm")) {
|
|
99
|
+
this.done(this.selected.join(","));
|
|
100
|
+
} else if (kb.matches(data, "tui.select.cancel")) {
|
|
101
|
+
this.done(undefined);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private buildDisplayItems(): DisplayItem[] {
|
|
106
|
+
const items: DisplayItem[] = [];
|
|
107
|
+
|
|
108
|
+
// Selected items first, in order
|
|
109
|
+
for (let i = 0; i < this.selected.length; i++) {
|
|
110
|
+
const id = this.selected[i];
|
|
111
|
+
const option = this.options.find((o) => o.id === id);
|
|
112
|
+
if (option) {
|
|
113
|
+
items.push({ option, selected: true, position: i + 1 });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Unselected items, in original options order
|
|
118
|
+
for (const option of this.options) {
|
|
119
|
+
if (!this.selected.includes(option.id)) {
|
|
120
|
+
items.push({ option, selected: false, position: 0 });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return items;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private toggleCurrent(items: DisplayItem[]): void {
|
|
128
|
+
const item = items[this.cursorIndex];
|
|
129
|
+
if (!item) return;
|
|
130
|
+
|
|
131
|
+
const id = item.option.id;
|
|
132
|
+
if (item.selected) {
|
|
133
|
+
// Remove from selected
|
|
134
|
+
this.selected = this.selected.filter((s) => s !== id);
|
|
135
|
+
} else {
|
|
136
|
+
// Add to end of selected
|
|
137
|
+
this.selected.push(id);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private moveUp(items: DisplayItem[]): void {
|
|
142
|
+
const item = items[this.cursorIndex];
|
|
143
|
+
if (!item?.selected) return;
|
|
144
|
+
|
|
145
|
+
const idx = this.selected.indexOf(item.option.id);
|
|
146
|
+
if (idx <= 0) return;
|
|
147
|
+
|
|
148
|
+
// Swap with previous in selected array
|
|
149
|
+
[this.selected[idx - 1], this.selected[idx]] = [this.selected[idx], this.selected[idx - 1]];
|
|
150
|
+
// Move cursor up to follow the item
|
|
151
|
+
this.cursorIndex--;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private moveDown(items: DisplayItem[]): void {
|
|
155
|
+
const item = items[this.cursorIndex];
|
|
156
|
+
if (!item?.selected) return;
|
|
157
|
+
|
|
158
|
+
const idx = this.selected.indexOf(item.option.id);
|
|
159
|
+
if (idx < 0 || idx >= this.selected.length - 1) return;
|
|
160
|
+
|
|
161
|
+
// Swap with next in selected array
|
|
162
|
+
[this.selected[idx], this.selected[idx + 1]] = [this.selected[idx + 1], this.selected[idx]];
|
|
163
|
+
// Move cursor down to follow the item
|
|
164
|
+
this.cursorIndex++;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings list component with support for cycling values and string input.
|
|
3
|
+
* Based on @earendil-works/pi-tui SettingsList, extended with string editing.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type Component,
|
|
8
|
+
fuzzyFilter,
|
|
9
|
+
getKeybindings,
|
|
10
|
+
Input,
|
|
11
|
+
matchesKey,
|
|
12
|
+
truncateToWidth,
|
|
13
|
+
visibleWidth,
|
|
14
|
+
wrapTextWithAnsi,
|
|
15
|
+
} from "@earendil-works/pi-tui";
|
|
16
|
+
|
|
17
|
+
export interface SettingItem {
|
|
18
|
+
/** Unique identifier for this setting */
|
|
19
|
+
id: string;
|
|
20
|
+
/** Display label (left side) */
|
|
21
|
+
label: string;
|
|
22
|
+
/** Optional description shown when selected */
|
|
23
|
+
description?: string;
|
|
24
|
+
/** Current value to display (right side) */
|
|
25
|
+
currentValue: string;
|
|
26
|
+
/**
|
|
27
|
+
* If provided, Enter/Space cycles through these values.
|
|
28
|
+
* If undefined/empty, the setting is treated as a string input.
|
|
29
|
+
*/
|
|
30
|
+
values?: string[];
|
|
31
|
+
/** If provided, Enter opens this submenu. Receives current value and done callback. */
|
|
32
|
+
submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
|
|
33
|
+
/** If false, item is selectable but cannot be edited or changed. */
|
|
34
|
+
editable?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SettingsListTheme {
|
|
38
|
+
label: (text: string, selected: boolean) => string;
|
|
39
|
+
value: (text: string, selected: boolean) => string;
|
|
40
|
+
description: (text: string) => string;
|
|
41
|
+
cursor: string;
|
|
42
|
+
hint: (text: string) => string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SettingsListOptions {
|
|
46
|
+
enableSearch?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class SettingsList implements Component {
|
|
50
|
+
private items: SettingItem[];
|
|
51
|
+
private filteredItems: SettingItem[];
|
|
52
|
+
private theme: SettingsListTheme;
|
|
53
|
+
private selectedIndex = 0;
|
|
54
|
+
private maxVisible: number;
|
|
55
|
+
private onChange: (id: string, newValue: string) => void;
|
|
56
|
+
private onCancel: () => void;
|
|
57
|
+
private searchInput?: Input;
|
|
58
|
+
private searchEnabled: boolean;
|
|
59
|
+
|
|
60
|
+
// Submenu state
|
|
61
|
+
private submenuComponent: Component | null = null;
|
|
62
|
+
private submenuItemIndex: number | null = null;
|
|
63
|
+
|
|
64
|
+
// String editing state
|
|
65
|
+
private editingInput: Input | null = null;
|
|
66
|
+
private editingItemIndex: number | null = null;
|
|
67
|
+
|
|
68
|
+
constructor(
|
|
69
|
+
items: SettingItem[],
|
|
70
|
+
maxVisible: number,
|
|
71
|
+
theme: SettingsListTheme,
|
|
72
|
+
onChange: (id: string, newValue: string) => void,
|
|
73
|
+
onCancel: () => void,
|
|
74
|
+
options: SettingsListOptions = {},
|
|
75
|
+
) {
|
|
76
|
+
this.items = items;
|
|
77
|
+
this.filteredItems = items;
|
|
78
|
+
this.maxVisible = maxVisible;
|
|
79
|
+
this.theme = theme;
|
|
80
|
+
this.onChange = onChange;
|
|
81
|
+
this.onCancel = onCancel;
|
|
82
|
+
this.searchEnabled = options.enableSearch ?? false;
|
|
83
|
+
if (this.searchEnabled) {
|
|
84
|
+
this.searchInput = new Input();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Update an item's currentValue */
|
|
89
|
+
updateValue(id: string, newValue: string): void {
|
|
90
|
+
const item = this.items.find((i) => i.id === id);
|
|
91
|
+
if (item) {
|
|
92
|
+
item.currentValue = newValue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
invalidate(): void {
|
|
97
|
+
this.submenuComponent?.invalidate?.();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
render(width: number): string[] {
|
|
101
|
+
// If submenu is active, render it instead
|
|
102
|
+
if (this.submenuComponent) {
|
|
103
|
+
return this.submenuComponent.render(width);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return this.renderMainList(width);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private renderMainList(width: number): string[] {
|
|
110
|
+
const lines: string[] = [];
|
|
111
|
+
|
|
112
|
+
if (this.searchEnabled && this.searchInput && !this.editingInput) {
|
|
113
|
+
lines.push(...this.searchInput.render(width));
|
|
114
|
+
lines.push("");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (this.items.length === 0) {
|
|
118
|
+
lines.push(this.theme.hint(" No settings available"));
|
|
119
|
+
if (this.searchEnabled) {
|
|
120
|
+
this.addHintLine(lines);
|
|
121
|
+
}
|
|
122
|
+
return lines;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
|
126
|
+
if (displayItems.length === 0) {
|
|
127
|
+
lines.push(this.theme.hint(" No matching settings"));
|
|
128
|
+
this.addHintLine(lines);
|
|
129
|
+
return lines;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Calculate visible range with scrolling
|
|
133
|
+
const startIndex = Math.max(
|
|
134
|
+
0,
|
|
135
|
+
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible),
|
|
136
|
+
);
|
|
137
|
+
const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length);
|
|
138
|
+
|
|
139
|
+
// Calculate max label width for alignment
|
|
140
|
+
const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label))));
|
|
141
|
+
|
|
142
|
+
// Render visible items
|
|
143
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
144
|
+
const item = displayItems[i];
|
|
145
|
+
if (!item) continue;
|
|
146
|
+
|
|
147
|
+
const isSelected = i === this.selectedIndex;
|
|
148
|
+
const isEditing = this.editingInput && this.editingItemIndex === i;
|
|
149
|
+
const prefix = isSelected ? this.theme.cursor : " ";
|
|
150
|
+
const prefixWidth = visibleWidth(prefix);
|
|
151
|
+
|
|
152
|
+
// Pad label to align values
|
|
153
|
+
const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
|
|
154
|
+
const labelText = this.theme.label(labelPadded, isSelected);
|
|
155
|
+
|
|
156
|
+
// Calculate space for value
|
|
157
|
+
const separator = " ";
|
|
158
|
+
const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
|
|
159
|
+
const valueMaxWidth = width - usedWidth - 2;
|
|
160
|
+
|
|
161
|
+
if (isEditing && this.editingInput) {
|
|
162
|
+
// Render inline input for string editing
|
|
163
|
+
const inputLines = this.editingInput.render(valueMaxWidth);
|
|
164
|
+
const inputLine = inputLines[0] ?? "";
|
|
165
|
+
lines.push(prefix + labelText + separator + inputLine);
|
|
166
|
+
} else {
|
|
167
|
+
const valueText = this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected);
|
|
168
|
+
lines.push(prefix + labelText + separator + valueText);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Add scroll indicator if needed
|
|
173
|
+
if (startIndex > 0 || endIndex < displayItems.length) {
|
|
174
|
+
const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;
|
|
175
|
+
lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, "")));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Add description for selected item
|
|
179
|
+
const selectedItem = displayItems[this.selectedIndex];
|
|
180
|
+
if (selectedItem?.description && !this.editingInput) {
|
|
181
|
+
lines.push("");
|
|
182
|
+
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
|
|
183
|
+
for (const line of wrappedDesc) {
|
|
184
|
+
lines.push(this.theme.description(` ${line}`));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Add hint
|
|
189
|
+
this.addHintLine(lines);
|
|
190
|
+
|
|
191
|
+
return lines;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
handleInput(data: string): void {
|
|
195
|
+
// If editing a string value, handle input for the editor
|
|
196
|
+
if (this.editingInput) {
|
|
197
|
+
this.handleEditingInput(data);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// If submenu is active, delegate all input to it
|
|
202
|
+
if (this.submenuComponent) {
|
|
203
|
+
this.submenuComponent.handleInput?.(data);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Main list input handling
|
|
208
|
+
const kb = getKeybindings();
|
|
209
|
+
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
|
210
|
+
|
|
211
|
+
if (kb.matches(data, "tui.select.up")) {
|
|
212
|
+
if (displayItems.length === 0) return;
|
|
213
|
+
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
|
|
214
|
+
} else if (kb.matches(data, "tui.select.down")) {
|
|
215
|
+
if (displayItems.length === 0) return;
|
|
216
|
+
this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;
|
|
217
|
+
} else if (kb.matches(data, "tui.select.confirm") || data === " ") {
|
|
218
|
+
this.activateItem();
|
|
219
|
+
} else if (kb.matches(data, "tui.select.cancel")) {
|
|
220
|
+
this.onCancel();
|
|
221
|
+
} else if (this.searchEnabled && this.searchInput) {
|
|
222
|
+
const sanitized = data.replace(/ /g, "");
|
|
223
|
+
if (!sanitized) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
this.searchInput.handleInput(sanitized);
|
|
227
|
+
this.applyFilter(this.searchInput.getValue());
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private handleEditingInput(data: string): void {
|
|
232
|
+
if (!this.editingInput) return;
|
|
233
|
+
|
|
234
|
+
// Enter: confirm edit
|
|
235
|
+
if (matchesKey(data, "enter")) {
|
|
236
|
+
this.confirmEdit();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Escape: cancel edit
|
|
241
|
+
if (matchesKey(data, "escape")) {
|
|
242
|
+
this.cancelEdit();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Pass other input to the editor
|
|
247
|
+
this.editingInput.handleInput(data);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private confirmEdit(): void {
|
|
251
|
+
if (!this.editingInput || this.editingItemIndex === null) return;
|
|
252
|
+
|
|
253
|
+
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
|
254
|
+
const item = displayItems[this.editingItemIndex];
|
|
255
|
+
if (item) {
|
|
256
|
+
const newValue = this.editingInput.getValue();
|
|
257
|
+
item.currentValue = newValue;
|
|
258
|
+
this.onChange(item.id, newValue);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
this.editingInput = null;
|
|
262
|
+
this.editingItemIndex = null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private cancelEdit(): void {
|
|
266
|
+
this.editingInput = null;
|
|
267
|
+
this.editingItemIndex = null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private activateItem(): void {
|
|
271
|
+
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
|
272
|
+
const item = displayItems[this.selectedIndex];
|
|
273
|
+
if (!item) return;
|
|
274
|
+
|
|
275
|
+
if (item.editable === false) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (item.submenu) {
|
|
280
|
+
// Open submenu, passing current value so it can pre-select correctly
|
|
281
|
+
this.submenuItemIndex = this.selectedIndex;
|
|
282
|
+
this.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => {
|
|
283
|
+
if (selectedValue !== undefined) {
|
|
284
|
+
item.currentValue = selectedValue;
|
|
285
|
+
this.onChange(item.id, selectedValue);
|
|
286
|
+
}
|
|
287
|
+
this.closeSubmenu();
|
|
288
|
+
});
|
|
289
|
+
} else if (item.values && item.values.length > 0) {
|
|
290
|
+
// Cycle through values
|
|
291
|
+
const currentIndex = item.values.indexOf(item.currentValue);
|
|
292
|
+
const nextIndex = (currentIndex + 1) % item.values.length;
|
|
293
|
+
const newValue = item.values[nextIndex];
|
|
294
|
+
item.currentValue = newValue;
|
|
295
|
+
this.onChange(item.id, newValue);
|
|
296
|
+
} else {
|
|
297
|
+
// String input - start editing
|
|
298
|
+
this.editingItemIndex = this.selectedIndex;
|
|
299
|
+
this.editingInput = new Input();
|
|
300
|
+
this.editingInput.setValue(item.currentValue);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private closeSubmenu(): void {
|
|
305
|
+
this.submenuComponent = null;
|
|
306
|
+
// Restore selection to the item that opened the submenu
|
|
307
|
+
if (this.submenuItemIndex !== null) {
|
|
308
|
+
this.selectedIndex = this.submenuItemIndex;
|
|
309
|
+
this.submenuItemIndex = null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private applyFilter(query: string): void {
|
|
314
|
+
this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label);
|
|
315
|
+
this.selectedIndex = 0;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private addHintLine(lines: string[]): void {
|
|
319
|
+
lines.push("");
|
|
320
|
+
if (this.editingInput) {
|
|
321
|
+
lines.push(this.theme.hint(" Enter to confirm · Esc to cancel"));
|
|
322
|
+
} else if (this.searchEnabled) {
|
|
323
|
+
lines.push(this.theme.hint(" Type to search · Enter/Space to change · Esc to cancel"));
|
|
324
|
+
} else {
|
|
325
|
+
lines.push(this.theme.hint(" Enter/Space to change · Esc to cancel"));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi extension that provides /extension-settings command.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Container, Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import { OrderedMultiSelect } from "./components/ordered-multi-select.js";
|
|
9
|
+
import { type SettingItem, SettingsList } from "./components/settings-list.js";
|
|
10
|
+
import { getSetting, setSetting } from "./settings/storage.js";
|
|
11
|
+
import type { SettingDefinition } from "./settings/types.js";
|
|
12
|
+
|
|
13
|
+
interface RegistrationPayload {
|
|
14
|
+
name: string;
|
|
15
|
+
settings: SettingDefinition[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function piLibExtension(pi: ExtensionAPI) {
|
|
19
|
+
// Local registry - stores settings registered via events
|
|
20
|
+
const registry = new Map<string, SettingDefinition[]>();
|
|
21
|
+
|
|
22
|
+
// Listen for registration events from other extensions
|
|
23
|
+
pi.events.on("pi-extension-settings:register", (data) => {
|
|
24
|
+
const { name, settings } = data as RegistrationPayload;
|
|
25
|
+
registry.set(name, settings);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
pi.registerCommand("extension-settings", {
|
|
29
|
+
description: "Configure settings for all extensions",
|
|
30
|
+
handler: async (_args, ctx) => {
|
|
31
|
+
if (registry.size === 0) {
|
|
32
|
+
ctx.ui.notify(
|
|
33
|
+
"No extensions have registered settings. Ensure pi-extension-settings is listed before consumer extensions in your packages array in ~/.pi/settings.json.",
|
|
34
|
+
"info",
|
|
35
|
+
);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Sort extensions by name
|
|
40
|
+
const sortedExtensions = Array.from(registry.entries()).sort(([a], [b]) => a.localeCompare(b));
|
|
41
|
+
|
|
42
|
+
await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
43
|
+
const container = new Container();
|
|
44
|
+
|
|
45
|
+
// Title
|
|
46
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Extension Settings")), 1, 1));
|
|
47
|
+
|
|
48
|
+
// Build items grouped by extension
|
|
49
|
+
const items: SettingItem[] = [];
|
|
50
|
+
|
|
51
|
+
for (const [extName, settings] of sortedExtensions) {
|
|
52
|
+
// Add extension header as a non-interactive item
|
|
53
|
+
items.push({
|
|
54
|
+
id: `__header__${extName}`,
|
|
55
|
+
label: theme.bold(extName),
|
|
56
|
+
currentValue: "",
|
|
57
|
+
values: undefined, // No cycling - acts as header
|
|
58
|
+
editable: false,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Add each setting
|
|
62
|
+
for (const setting of settings) {
|
|
63
|
+
const currentValue = getSetting(extName, setting.id, setting.defaultValue) ?? setting.defaultValue;
|
|
64
|
+
|
|
65
|
+
if (setting.options && setting.options.length > 0) {
|
|
66
|
+
// Ordered multi-select: opens a submenu
|
|
67
|
+
items.push({
|
|
68
|
+
id: `${extName}::${setting.id}`,
|
|
69
|
+
label: ` ${setting.label}`,
|
|
70
|
+
description: setting.description,
|
|
71
|
+
currentValue,
|
|
72
|
+
submenu: (val, submenuDone) => {
|
|
73
|
+
return new OrderedMultiSelect(setting.options!, val, getSettingsListTheme(), submenuDone);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
} else {
|
|
77
|
+
items.push({
|
|
78
|
+
id: `${extName}::${setting.id}`,
|
|
79
|
+
label: ` ${setting.label}`,
|
|
80
|
+
description: setting.description,
|
|
81
|
+
currentValue,
|
|
82
|
+
values: setting.values,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const settingsList = new SettingsList(
|
|
89
|
+
items,
|
|
90
|
+
Math.min(items.length + 2, 20),
|
|
91
|
+
getSettingsListTheme(),
|
|
92
|
+
(id, newValue) => {
|
|
93
|
+
// Skip headers
|
|
94
|
+
if (id.startsWith("__header__")) return;
|
|
95
|
+
|
|
96
|
+
// Parse extension::settingId
|
|
97
|
+
const [extensionName, settingId] = id.split("::");
|
|
98
|
+
if (extensionName && settingId) {
|
|
99
|
+
setSetting(extensionName, settingId, newValue);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
() => {
|
|
103
|
+
done(undefined);
|
|
104
|
+
},
|
|
105
|
+
{ enableSearch: true },
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
container.addChild(settingsList);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
render(width: number) {
|
|
112
|
+
return container.render(width);
|
|
113
|
+
},
|
|
114
|
+
invalidate() {
|
|
115
|
+
container.invalidate();
|
|
116
|
+
},
|
|
117
|
+
handleInput(data: string) {
|
|
118
|
+
settingsList.handleInput?.(data);
|
|
119
|
+
tui.requestRender();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -33,7 +33,11 @@
|
|
|
33
33
|
* setSetting("my-extension", "timeout", "60");
|
|
34
34
|
* ```
|
|
35
35
|
*/
|
|
36
|
+
|
|
37
|
+
// Extension entry point
|
|
36
38
|
export { default } from "./extension.js";
|
|
39
|
+
|
|
40
|
+
// Stateless helpers for reading/writing settings
|
|
37
41
|
export { getSetting, setSetting } from "./settings/storage.js";
|
|
42
|
+
// Types for documentation/type-safety when emitting events
|
|
38
43
|
export type { OrderedListOption, SettingDefinition } from "./settings/types.js";
|
|
39
|
-
//# sourceMappingURL=index.d.ts.map
|