@aliou/pi-guardrails 0.2.0 → 0.3.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/README.md +158 -3
- package/array-editor.ts +213 -0
- package/config-schema.ts +54 -0
- package/config.ts +160 -0
- package/events.ts +32 -0
- package/hooks/index.ts +5 -4
- package/hooks/permission-gate.ts +170 -121
- package/hooks/prevent-brew.ts +26 -22
- package/hooks/protect-env-files.ts +95 -80
- package/index.ts +15 -2
- package/package.json +3 -3
- package/pattern-editor.ts +284 -0
- package/sectioned-settings.ts +345 -0
- package/settings-command.ts +416 -0
|
@@ -0,0 +1,284 @@
|
|
|
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 an array of {pattern, description} objects.
|
|
12
|
+
*
|
|
13
|
+
* List mode: navigate, delete with 'd', add with 'a', edit with 'e'/Enter.
|
|
14
|
+
* Form mode: two-field form (pattern + description), Tab to switch fields,
|
|
15
|
+
* Enter to submit, Escape to cancel.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface PatternItem {
|
|
19
|
+
pattern: string;
|
|
20
|
+
description: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PatternEditorOptions {
|
|
24
|
+
label: string;
|
|
25
|
+
items: PatternItem[];
|
|
26
|
+
theme: SettingsListTheme;
|
|
27
|
+
onSave: (items: PatternItem[]) => void;
|
|
28
|
+
onDone: () => void;
|
|
29
|
+
maxVisible?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Field = "pattern" | "description";
|
|
33
|
+
|
|
34
|
+
export class PatternEditor implements Component {
|
|
35
|
+
private items: PatternItem[];
|
|
36
|
+
private label: string;
|
|
37
|
+
private theme: SettingsListTheme;
|
|
38
|
+
private onSave: (items: PatternItem[]) => void;
|
|
39
|
+
private onDone: () => void;
|
|
40
|
+
private selectedIndex = 0;
|
|
41
|
+
private maxVisible: number;
|
|
42
|
+
private mode: "list" | "add" | "edit" = "list";
|
|
43
|
+
private editIndex = -1;
|
|
44
|
+
|
|
45
|
+
// Form state
|
|
46
|
+
private patternInput: Input;
|
|
47
|
+
private descriptionInput: Input;
|
|
48
|
+
private activeField: Field = "pattern";
|
|
49
|
+
|
|
50
|
+
constructor(options: PatternEditorOptions) {
|
|
51
|
+
this.items = [...options.items];
|
|
52
|
+
this.label = options.label;
|
|
53
|
+
this.theme = options.theme;
|
|
54
|
+
this.onSave = options.onSave;
|
|
55
|
+
this.onDone = options.onDone;
|
|
56
|
+
this.maxVisible = options.maxVisible ?? 10;
|
|
57
|
+
|
|
58
|
+
this.patternInput = new Input();
|
|
59
|
+
this.descriptionInput = new Input();
|
|
60
|
+
|
|
61
|
+
this.patternInput.onSubmit = () => this.submitOrSwitchField();
|
|
62
|
+
this.patternInput.onEscape = () => this.cancelForm();
|
|
63
|
+
|
|
64
|
+
this.descriptionInput.onSubmit = () => this.submitOrSwitchField();
|
|
65
|
+
this.descriptionInput.onEscape = () => this.cancelForm();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private submitOrSwitchField() {
|
|
69
|
+
// If on pattern field and it has content, move to description
|
|
70
|
+
if (this.activeField === "pattern" && this.patternInput.getValue().trim()) {
|
|
71
|
+
this.activeField = "description";
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// If on description field (or pattern is empty), submit
|
|
76
|
+
this.submitForm();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private submitForm() {
|
|
80
|
+
const pattern = this.patternInput.getValue().trim();
|
|
81
|
+
const description = this.descriptionInput.getValue().trim();
|
|
82
|
+
|
|
83
|
+
if (!pattern) {
|
|
84
|
+
this.cancelForm();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const item: PatternItem = {
|
|
89
|
+
pattern,
|
|
90
|
+
description: description || pattern,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
if (this.mode === "edit") {
|
|
94
|
+
this.items[this.editIndex] = item;
|
|
95
|
+
} else {
|
|
96
|
+
this.items.push(item);
|
|
97
|
+
this.selectedIndex = this.items.length - 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.onSave([...this.items]);
|
|
101
|
+
this.cancelForm();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private cancelForm() {
|
|
105
|
+
this.mode = "list";
|
|
106
|
+
this.editIndex = -1;
|
|
107
|
+
this.activeField = "pattern";
|
|
108
|
+
this.patternInput.setValue("");
|
|
109
|
+
this.descriptionInput.setValue("");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private startEdit() {
|
|
113
|
+
if (this.items.length === 0) return;
|
|
114
|
+
const item = this.items[this.selectedIndex];
|
|
115
|
+
if (!item) return;
|
|
116
|
+
this.editIndex = this.selectedIndex;
|
|
117
|
+
this.mode = "edit";
|
|
118
|
+
this.activeField = "pattern";
|
|
119
|
+
this.patternInput.setValue(item.pattern);
|
|
120
|
+
this.descriptionInput.setValue(item.description);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private deleteSelected() {
|
|
124
|
+
if (this.items.length === 0) return;
|
|
125
|
+
this.items.splice(this.selectedIndex, 1);
|
|
126
|
+
if (this.selectedIndex >= this.items.length) {
|
|
127
|
+
this.selectedIndex = Math.max(0, this.items.length - 1);
|
|
128
|
+
}
|
|
129
|
+
this.onSave([...this.items]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
invalidate() {}
|
|
133
|
+
|
|
134
|
+
render(width: number): string[] {
|
|
135
|
+
const lines: string[] = [];
|
|
136
|
+
lines.push(this.theme.label(` ${this.label}`, true));
|
|
137
|
+
lines.push("");
|
|
138
|
+
|
|
139
|
+
if (this.mode === "add" || this.mode === "edit") {
|
|
140
|
+
return [...lines, ...this.renderFormMode(width)];
|
|
141
|
+
}
|
|
142
|
+
return [...lines, ...this.renderListMode(width)];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private renderListMode(width: number): string[] {
|
|
146
|
+
const lines: string[] = [];
|
|
147
|
+
|
|
148
|
+
if (this.items.length === 0) {
|
|
149
|
+
lines.push(this.theme.hint(" (empty)"));
|
|
150
|
+
} else {
|
|
151
|
+
const startIndex = Math.max(
|
|
152
|
+
0,
|
|
153
|
+
Math.min(
|
|
154
|
+
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
|
155
|
+
this.items.length - this.maxVisible,
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
const endIndex = Math.min(
|
|
159
|
+
startIndex + this.maxVisible,
|
|
160
|
+
this.items.length,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
164
|
+
const item = this.items[i];
|
|
165
|
+
if (!item) continue;
|
|
166
|
+
const isSelected = i === this.selectedIndex;
|
|
167
|
+
const prefix = isSelected ? this.theme.cursor : " ";
|
|
168
|
+
const prefixWidth = visibleWidth(prefix);
|
|
169
|
+
const maxItemWidth = width - prefixWidth - 2;
|
|
170
|
+
const display = `${item.description} (${item.pattern})`;
|
|
171
|
+
const text = this.theme.value(
|
|
172
|
+
truncateToWidth(display, maxItemWidth, ""),
|
|
173
|
+
isSelected,
|
|
174
|
+
);
|
|
175
|
+
lines.push(prefix + text);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (startIndex > 0 || endIndex < this.items.length) {
|
|
179
|
+
lines.push(
|
|
180
|
+
this.theme.hint(` (${this.selectedIndex + 1}/${this.items.length})`),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
lines.push("");
|
|
186
|
+
lines.push(
|
|
187
|
+
this.theme.hint(" a: add · e/Enter: edit · d: delete · Esc: back"),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
return lines;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private renderFormMode(width: number): string[] {
|
|
194
|
+
const lines: string[] = [];
|
|
195
|
+
const inputWidth = width - 4;
|
|
196
|
+
const isEdit = this.mode === "edit";
|
|
197
|
+
|
|
198
|
+
const patternActive = this.activeField === "pattern";
|
|
199
|
+
const descActive = this.activeField === "description";
|
|
200
|
+
|
|
201
|
+
// Title
|
|
202
|
+
lines.push(this.theme.hint(isEdit ? " Edit pattern:" : " New pattern:"));
|
|
203
|
+
lines.push("");
|
|
204
|
+
|
|
205
|
+
// Pattern field
|
|
206
|
+
const patternLabel = " Pattern (regex):";
|
|
207
|
+
lines.push(
|
|
208
|
+
patternActive
|
|
209
|
+
? this.theme.label(patternLabel, true)
|
|
210
|
+
: this.theme.hint(patternLabel),
|
|
211
|
+
);
|
|
212
|
+
lines.push(` ${this.patternInput.render(inputWidth).join("")}`);
|
|
213
|
+
lines.push("");
|
|
214
|
+
|
|
215
|
+
// Description field
|
|
216
|
+
const descLabel = " Description:";
|
|
217
|
+
lines.push(
|
|
218
|
+
descActive
|
|
219
|
+
? this.theme.label(descLabel, true)
|
|
220
|
+
: this.theme.hint(descLabel),
|
|
221
|
+
);
|
|
222
|
+
lines.push(` ${this.descriptionInput.render(inputWidth).join("")}`);
|
|
223
|
+
lines.push("");
|
|
224
|
+
|
|
225
|
+
lines.push(
|
|
226
|
+
this.theme.hint(" Tab: switch field · Enter: next/submit · Esc: cancel"),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
return lines;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
handleInput(data: string) {
|
|
233
|
+
if (this.mode === "add" || this.mode === "edit") {
|
|
234
|
+
this.handleFormInput(data);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// List mode
|
|
239
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
240
|
+
if (this.items.length === 0) return;
|
|
241
|
+
this.selectedIndex =
|
|
242
|
+
this.selectedIndex === 0
|
|
243
|
+
? this.items.length - 1
|
|
244
|
+
: this.selectedIndex - 1;
|
|
245
|
+
} else if (matchesKey(data, Key.down) || data === "j") {
|
|
246
|
+
if (this.items.length === 0) return;
|
|
247
|
+
this.selectedIndex =
|
|
248
|
+
this.selectedIndex === this.items.length - 1
|
|
249
|
+
? 0
|
|
250
|
+
: this.selectedIndex + 1;
|
|
251
|
+
} else if (data === "a" || data === "A") {
|
|
252
|
+
this.mode = "add";
|
|
253
|
+
this.activeField = "pattern";
|
|
254
|
+
this.patternInput.setValue("");
|
|
255
|
+
this.descriptionInput.setValue("");
|
|
256
|
+
} else if (data === "e" || data === "E" || matchesKey(data, Key.enter)) {
|
|
257
|
+
this.startEdit();
|
|
258
|
+
} else if (data === "d" || data === "D") {
|
|
259
|
+
this.deleteSelected();
|
|
260
|
+
} else if (matchesKey(data, Key.escape)) {
|
|
261
|
+
this.onDone();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private handleFormInput(data: string) {
|
|
266
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab"))) {
|
|
267
|
+
this.activeField =
|
|
268
|
+
this.activeField === "pattern" ? "description" : "pattern";
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (matchesKey(data, Key.escape)) {
|
|
273
|
+
this.cancelForm();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Delegate to active input
|
|
278
|
+
const activeInput =
|
|
279
|
+
this.activeField === "pattern"
|
|
280
|
+
? this.patternInput
|
|
281
|
+
: this.descriptionInput;
|
|
282
|
+
activeInput.handleInput(data);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
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
|
+
}
|
|
29
|
+
|
|
30
|
+
interface FlatEntry {
|
|
31
|
+
type: "section" | "item";
|
|
32
|
+
sectionLabel?: string;
|
|
33
|
+
item?: SettingItem;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class SectionedSettings implements Component {
|
|
37
|
+
private sections: SettingsSection[];
|
|
38
|
+
private flatEntries: FlatEntry[];
|
|
39
|
+
private filteredEntries: FlatEntry[];
|
|
40
|
+
private theme: SettingsListTheme;
|
|
41
|
+
private selectedIndex: number; // index into selectable items only
|
|
42
|
+
private maxVisible: number;
|
|
43
|
+
private onChange: (id: string, newValue: string) => void;
|
|
44
|
+
private onCancel: () => void;
|
|
45
|
+
private searchInput?: Input;
|
|
46
|
+
private searchEnabled: boolean;
|
|
47
|
+
private submenuComponent: Component | null = null;
|
|
48
|
+
private submenuItemIndex: number | null = null;
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
sections: SettingsSection[],
|
|
52
|
+
maxVisible: number,
|
|
53
|
+
theme: SettingsListTheme,
|
|
54
|
+
onChange: (id: string, newValue: string) => void,
|
|
55
|
+
onCancel: () => void,
|
|
56
|
+
options: SectionedSettingsOptions = {},
|
|
57
|
+
) {
|
|
58
|
+
this.sections = sections;
|
|
59
|
+
this.maxVisible = maxVisible;
|
|
60
|
+
this.theme = theme;
|
|
61
|
+
this.onChange = onChange;
|
|
62
|
+
this.onCancel = onCancel;
|
|
63
|
+
this.searchEnabled = options.enableSearch ?? false;
|
|
64
|
+
this.selectedIndex = 0;
|
|
65
|
+
|
|
66
|
+
if (this.searchEnabled) {
|
|
67
|
+
this.searchInput = new Input();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.flatEntries = this.buildFlatEntries(sections);
|
|
71
|
+
this.filteredEntries = this.flatEntries;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private buildFlatEntries(sections: SettingsSection[]): FlatEntry[] {
|
|
75
|
+
const entries: FlatEntry[] = [];
|
|
76
|
+
for (const section of sections) {
|
|
77
|
+
entries.push({ type: "section", sectionLabel: section.label });
|
|
78
|
+
for (const item of section.items) {
|
|
79
|
+
entries.push({ type: "item", item });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return entries;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private getSelectableItems(): SettingItem[] {
|
|
86
|
+
return this.filteredEntries
|
|
87
|
+
.filter((e) => e.type === "item" && e.item)
|
|
88
|
+
.map((e) => e.item as SettingItem);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
updateValue(id: string, newValue: string): void {
|
|
92
|
+
for (const section of this.sections) {
|
|
93
|
+
const item = section.items.find((i) => i.id === id);
|
|
94
|
+
if (item) {
|
|
95
|
+
item.currentValue = newValue;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Returns true when a submenu is open (caller should not intercept input). */
|
|
102
|
+
hasActiveSubmenu(): boolean {
|
|
103
|
+
return this.submenuComponent !== null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
invalidate(): void {
|
|
107
|
+
this.submenuComponent?.invalidate?.();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
render(width: number): string[] {
|
|
111
|
+
if (this.submenuComponent) {
|
|
112
|
+
return this.submenuComponent.render(width);
|
|
113
|
+
}
|
|
114
|
+
return this.renderMainList(width);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private renderMainList(width: number): string[] {
|
|
118
|
+
const lines: string[] = [];
|
|
119
|
+
|
|
120
|
+
if (this.searchEnabled && this.searchInput) {
|
|
121
|
+
lines.push(...this.searchInput.render(width));
|
|
122
|
+
lines.push("");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const allItems = this.getSelectableItems();
|
|
126
|
+
|
|
127
|
+
if (allItems.length === 0) {
|
|
128
|
+
lines.push(
|
|
129
|
+
this.theme.hint(
|
|
130
|
+
this.searchEnabled
|
|
131
|
+
? " No matching settings"
|
|
132
|
+
: " No settings available",
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
this.addHintLine(lines);
|
|
136
|
+
return lines;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Calculate max label width for alignment
|
|
140
|
+
const maxLabelWidth = Math.min(
|
|
141
|
+
30,
|
|
142
|
+
Math.max(...allItems.map((item) => visibleWidth(item.label))),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Build visible entries with their "selectable index"
|
|
146
|
+
let selectableIdx = -1;
|
|
147
|
+
const rendered: Array<{
|
|
148
|
+
line: string;
|
|
149
|
+
isSelected: boolean;
|
|
150
|
+
description?: string;
|
|
151
|
+
}> = [];
|
|
152
|
+
|
|
153
|
+
for (const entry of this.filteredEntries) {
|
|
154
|
+
if (entry.type === "section") {
|
|
155
|
+
// Section header - add blank line before (except first)
|
|
156
|
+
if (rendered.length > 0) {
|
|
157
|
+
rendered.push({ line: "", isSelected: false });
|
|
158
|
+
}
|
|
159
|
+
rendered.push({
|
|
160
|
+
line: this.theme.hint(` ${entry.sectionLabel}`),
|
|
161
|
+
isSelected: false,
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const item = entry.item;
|
|
167
|
+
if (!item) continue;
|
|
168
|
+
|
|
169
|
+
selectableIdx++;
|
|
170
|
+
const isSelected = selectableIdx === this.selectedIndex;
|
|
171
|
+
const prefix = isSelected ? this.theme.cursor : " ";
|
|
172
|
+
const prefixWidth = visibleWidth(prefix);
|
|
173
|
+
|
|
174
|
+
const labelPadded =
|
|
175
|
+
item.label +
|
|
176
|
+
" ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
|
|
177
|
+
const labelText = this.theme.label(labelPadded, isSelected);
|
|
178
|
+
|
|
179
|
+
const separator = " ";
|
|
180
|
+
const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
|
|
181
|
+
const valueMaxWidth = width - usedWidth - 2;
|
|
182
|
+
const valueText = this.theme.value(
|
|
183
|
+
truncateToWidth(item.currentValue, valueMaxWidth, ""),
|
|
184
|
+
isSelected,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
rendered.push({
|
|
188
|
+
line: prefix + labelText + separator + valueText,
|
|
189
|
+
isSelected,
|
|
190
|
+
description: isSelected ? item.description : undefined,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Scrolling: find the rendered index of the selected item
|
|
195
|
+
const selectedRenderedIdx = rendered.findIndex((r) => r.isSelected);
|
|
196
|
+
const totalLines = rendered.length;
|
|
197
|
+
const startLine = Math.max(
|
|
198
|
+
0,
|
|
199
|
+
Math.min(
|
|
200
|
+
selectedRenderedIdx - Math.floor(this.maxVisible / 2),
|
|
201
|
+
totalLines - this.maxVisible,
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
const endLine = Math.min(startLine + this.maxVisible, totalLines);
|
|
205
|
+
|
|
206
|
+
for (let i = startLine; i < endLine; i++) {
|
|
207
|
+
const r = rendered[i];
|
|
208
|
+
if (r) lines.push(r.line);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Scroll indicator
|
|
212
|
+
if (startLine > 0 || endLine < totalLines) {
|
|
213
|
+
lines.push(
|
|
214
|
+
this.theme.hint(` (${this.selectedIndex + 1}/${allItems.length})`),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Description for selected item
|
|
219
|
+
const selectedItem = allItems[this.selectedIndex];
|
|
220
|
+
if (selectedItem?.description) {
|
|
221
|
+
lines.push("");
|
|
222
|
+
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
|
|
223
|
+
for (const line of wrappedDesc) {
|
|
224
|
+
lines.push(this.theme.description(` ${line}`));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this.addHintLine(lines);
|
|
229
|
+
return lines;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
handleInput(data: string): void {
|
|
233
|
+
if (this.submenuComponent) {
|
|
234
|
+
this.submenuComponent.handleInput?.(data);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const items = this.getSelectableItems();
|
|
239
|
+
|
|
240
|
+
if (matchesKey(data, Key.up)) {
|
|
241
|
+
if (items.length === 0) return;
|
|
242
|
+
this.selectedIndex =
|
|
243
|
+
this.selectedIndex === 0 ? items.length - 1 : this.selectedIndex - 1;
|
|
244
|
+
} else if (matchesKey(data, Key.down)) {
|
|
245
|
+
if (items.length === 0) return;
|
|
246
|
+
this.selectedIndex =
|
|
247
|
+
this.selectedIndex === items.length - 1 ? 0 : this.selectedIndex + 1;
|
|
248
|
+
} else if (matchesKey(data, Key.enter) || data === " ") {
|
|
249
|
+
this.activateItem();
|
|
250
|
+
} else if (matchesKey(data, Key.escape)) {
|
|
251
|
+
this.onCancel();
|
|
252
|
+
} else if (this.searchEnabled && this.searchInput) {
|
|
253
|
+
const sanitized = data.replace(/ /g, "");
|
|
254
|
+
if (!sanitized) return;
|
|
255
|
+
this.searchInput.handleInput(sanitized);
|
|
256
|
+
this.applyFilter(this.searchInput.getValue());
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private activateItem(): void {
|
|
261
|
+
const items = this.getSelectableItems();
|
|
262
|
+
const item = items[this.selectedIndex];
|
|
263
|
+
if (!item) return;
|
|
264
|
+
|
|
265
|
+
if (item.submenu) {
|
|
266
|
+
this.submenuItemIndex = this.selectedIndex;
|
|
267
|
+
this.submenuComponent = item.submenu(
|
|
268
|
+
item.currentValue,
|
|
269
|
+
(selectedValue) => {
|
|
270
|
+
if (selectedValue !== undefined) {
|
|
271
|
+
item.currentValue = selectedValue;
|
|
272
|
+
this.onChange(item.id, selectedValue);
|
|
273
|
+
}
|
|
274
|
+
this.closeSubmenu();
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
} else if (item.values && item.values.length > 0) {
|
|
278
|
+
const currentIndex = item.values.indexOf(item.currentValue);
|
|
279
|
+
const nextIndex = (currentIndex + 1) % item.values.length;
|
|
280
|
+
const newValue = item.values[nextIndex] as string;
|
|
281
|
+
item.currentValue = newValue;
|
|
282
|
+
this.onChange(item.id, newValue);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private closeSubmenu(): void {
|
|
287
|
+
this.submenuComponent = null;
|
|
288
|
+
if (this.submenuItemIndex !== null) {
|
|
289
|
+
this.selectedIndex = this.submenuItemIndex;
|
|
290
|
+
this.submenuItemIndex = null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private applyFilter(query: string): void {
|
|
295
|
+
if (!query) {
|
|
296
|
+
this.filteredEntries = this.flatEntries;
|
|
297
|
+
this.selectedIndex = 0;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Filter items, keep section headers if they have matching items
|
|
302
|
+
const filtered: FlatEntry[] = [];
|
|
303
|
+
let currentSection: FlatEntry | null = null;
|
|
304
|
+
let sectionHasMatch = false;
|
|
305
|
+
|
|
306
|
+
for (const entry of this.flatEntries) {
|
|
307
|
+
if (entry.type === "section") {
|
|
308
|
+
// Flush previous section if it had matches
|
|
309
|
+
if (currentSection && sectionHasMatch) {
|
|
310
|
+
// Already added items under this section
|
|
311
|
+
}
|
|
312
|
+
currentSection = entry;
|
|
313
|
+
sectionHasMatch = false;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (entry.item) {
|
|
318
|
+
const label = entry.item.label.toLowerCase();
|
|
319
|
+
const q = query.toLowerCase();
|
|
320
|
+
if (label.includes(q)) {
|
|
321
|
+
// Add section header if first match in this section
|
|
322
|
+
if (currentSection && !sectionHasMatch) {
|
|
323
|
+
filtered.push(currentSection);
|
|
324
|
+
sectionHasMatch = true;
|
|
325
|
+
}
|
|
326
|
+
filtered.push(entry);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
this.filteredEntries = filtered;
|
|
332
|
+
this.selectedIndex = 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private addHintLine(lines: string[]): void {
|
|
336
|
+
lines.push("");
|
|
337
|
+
lines.push(
|
|
338
|
+
this.theme.hint(
|
|
339
|
+
this.searchEnabled
|
|
340
|
+
? " Type to search \u00B7 Enter/Space to change \u00B7 Esc to cancel"
|
|
341
|
+
: " Enter/Space to change \u00B7 Esc to cancel",
|
|
342
|
+
),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|