@aliou/pi-guardrails 0.5.4 → 0.6.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.
@@ -1,345 +0,0 @@
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
- }