avtc-pi-settings-ui 1.0.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/LICENSE +21 -0
- package/README.md +131 -0
- package/assets/images/settings-dropdown.png +0 -0
- package/assets/images/settings.png +0 -0
- package/index.ts +30 -0
- package/package.json +65 -0
- package/src/ctx-store.ts +34 -0
- package/src/custom-validation.ts +45 -0
- package/src/factory.ts +233 -0
- package/src/model-presets.ts +101 -0
- package/src/normalization.ts +421 -0
- package/src/persistence.ts +115 -0
- package/src/preset-resolver.ts +66 -0
- package/src/schema.ts +144 -0
- package/src/snippets/vendored/subscribe-to-dialog-coordinator.ts +98 -0
- package/src/snippets/vendored/subscribe-to-notifications.ts +150 -0
- package/src/type-definitions.ts +301 -0
- package/src/ui/modal-frame.ts +130 -0
- package/src/ui/picker-helpers.ts +68 -0
- package/src/ui/schema-tabs.ts +140 -0
- package/src/ui/settings-command.ts +321 -0
- package/src/ui/settings-modal.ts +414 -0
- package/src/ui/value-picker.ts +353 -0
- package/src/validation.ts +107 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SettingsTabsModal — tabbed settings dialog content renderer.
|
|
6
|
+
*
|
|
7
|
+
* Generic modal content that renders tabs of settings using pi-tui components.
|
|
8
|
+
* Designed to be used inside ModalFrame.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getSettingsListTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
13
|
+
import {
|
|
14
|
+
Box,
|
|
15
|
+
Container,
|
|
16
|
+
Input,
|
|
17
|
+
Key,
|
|
18
|
+
matchesKey,
|
|
19
|
+
SettingsList,
|
|
20
|
+
Spacer,
|
|
21
|
+
Text,
|
|
22
|
+
type SettingItem as TuiSettingItem,
|
|
23
|
+
wrapTextWithAnsi,
|
|
24
|
+
} from "@earendil-works/pi-tui";
|
|
25
|
+
import type { SettingItem, TabDefinition } from "../schema.js";
|
|
26
|
+
import { PICKER_VIEWPORT_MAX, ValuePickerSubmenu } from "./value-picker.js";
|
|
27
|
+
|
|
28
|
+
export interface TabSettingsOptions {
|
|
29
|
+
title: string;
|
|
30
|
+
buildTabs: () => TabDefinition[];
|
|
31
|
+
onChange: (id: string, value: unknown) => void;
|
|
32
|
+
onClose: () => void;
|
|
33
|
+
enableSearch?: boolean;
|
|
34
|
+
/** Optional description lookup — id → description text */
|
|
35
|
+
getDescription?: (id: string) => string | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Line overhead for maxVisible calculation in the settings dialog.
|
|
39
|
+
* ModalFrame frame = 2 (top border + bottom border)
|
|
40
|
+
* filter field = 1 (the always-active modal filter Input above the tab bar)
|
|
41
|
+
* pre-list = 2 (tab bar + spacer)
|
|
42
|
+
* hintLines = 2 (empty line + hint text from SettingsList.addHintLine)
|
|
43
|
+
* descSpacer = 1 (spacer between hint and description)
|
|
44
|
+
* descArea = 3 (fixed description lines appended in render())
|
|
45
|
+
* helpText = 1 (help text below bottom border from ModalFrame)
|
|
46
|
+
* overlayMargin = 2 (top + bottom margin=1 from overlay options)
|
|
47
|
+
* Total = 2 + 1 + 2 + 2 + 1 + 3 + 1 + 2 = 14
|
|
48
|
+
*/
|
|
49
|
+
const SETTINGS_DIALOG_OVERHEAD = 14;
|
|
50
|
+
|
|
51
|
+
/** Empty filter text — the live filter on a fresh modal open. Named (not a bare "") per lint. */
|
|
52
|
+
const EMPTY_FILTER = "";
|
|
53
|
+
|
|
54
|
+
/** Sentinel: no TUI provided for settings modal */
|
|
55
|
+
export const NO_TUI: { terminal?: { rows?: number } } | undefined = undefined;
|
|
56
|
+
|
|
57
|
+
export class SettingsTabsModal implements Component {
|
|
58
|
+
private buildTabsFn: () => TabDefinition[];
|
|
59
|
+
/** Tabs built once per open / tab switch (cached so filter keystrokes do NOT re-run
|
|
60
|
+
* buildSchemaTabGroups). Refreshed by the constructor and switchTab. */
|
|
61
|
+
private cachedTabs: TabDefinition[] = [];
|
|
62
|
+
private activeTab = 0;
|
|
63
|
+
private settingsList: SettingsList | null = null;
|
|
64
|
+
/**
|
|
65
|
+
* The pi-tui SettingsList keeps selectedIndex/filteredItems/maxVisible/submenuComponent
|
|
66
|
+
* private, but this modal reads/writes them to drive tab layout. This structural type
|
|
67
|
+
* names exactly which internals are accessed (no blanket any).
|
|
68
|
+
*/
|
|
69
|
+
private get listInternals(): {
|
|
70
|
+
selectedIndex: number;
|
|
71
|
+
filteredItems: SettingItem[] | undefined;
|
|
72
|
+
maxVisible: number;
|
|
73
|
+
submenuComponent: unknown;
|
|
74
|
+
} {
|
|
75
|
+
return this.settingsList as unknown as {
|
|
76
|
+
selectedIndex: number;
|
|
77
|
+
filteredItems: SettingItem[] | undefined;
|
|
78
|
+
maxVisible: number;
|
|
79
|
+
submenuComponent: unknown;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
private container!: Container;
|
|
83
|
+
private contentBox!: Box;
|
|
84
|
+
/** Persistent modal-owned filter field. Created ONCE in the constructor and re-added to
|
|
85
|
+
* `container` on each `rebuildContent` (so its text survives per-keystroke / per-tab-switch
|
|
86
|
+
* rebuilds — the list is rebuilt, the filter Input is not). */
|
|
87
|
+
private filterInput: Input;
|
|
88
|
+
/** The items currently shown (after applying the live filter). `SettingsList.selectedIndex`
|
|
89
|
+
* indexes THIS array, so Up/Down selection must resolve against it — not the unfiltered set. */
|
|
90
|
+
private currentVisibleItems: SettingItem[] = [];
|
|
91
|
+
private theme: Theme;
|
|
92
|
+
private onChange: (id: string, value: unknown) => void;
|
|
93
|
+
private onClose: () => void;
|
|
94
|
+
private enableSearch: boolean;
|
|
95
|
+
private tui?: { terminal?: { rows?: number } };
|
|
96
|
+
private selectedItem: SettingItem | null = null;
|
|
97
|
+
private descriptions: Map<string, string> = new Map();
|
|
98
|
+
private fixedMaxVisible!: number;
|
|
99
|
+
/** Max rows the value-picker dropdown may show (terminal-capped). Kept independent of
|
|
100
|
+
* fixedMaxVisible because a setting's presets (e.g. a model list) can outnumber its tab's
|
|
101
|
+
* items many times over. */
|
|
102
|
+
private pickerMaxRows!: number;
|
|
103
|
+
/** Rows reserved for the shared list/picker slot = max(fixedMaxVisible, pickerMaxRows). The
|
|
104
|
+
* modal always pads to this height so opening the dropdown never grows or shifts the modal. */
|
|
105
|
+
private listAreaRows!: number;
|
|
106
|
+
private maxTabItems!: number;
|
|
107
|
+
private getDescriptionFn: (id: string) => string | undefined;
|
|
108
|
+
|
|
109
|
+
constructor(options: TabSettingsOptions, theme: Theme, tui: { terminal?: { rows?: number } } | undefined) {
|
|
110
|
+
this.buildTabsFn = options.buildTabs;
|
|
111
|
+
this.theme = theme;
|
|
112
|
+
this.onChange = options.onChange;
|
|
113
|
+
this.onClose = options.onClose;
|
|
114
|
+
this.enableSearch = options.enableSearch ?? true;
|
|
115
|
+
this.tui = tui;
|
|
116
|
+
this.getDescriptionFn = options.getDescription ?? (() => undefined);
|
|
117
|
+
|
|
118
|
+
const tabs = this.buildTabsFn();
|
|
119
|
+
|
|
120
|
+
this.maxTabItems = Math.max(3, ...tabs.map((t) => t.settings.length));
|
|
121
|
+
this.recomputeFixedMaxVisible();
|
|
122
|
+
|
|
123
|
+
this.refreshTabs(tabs);
|
|
124
|
+
|
|
125
|
+
// Stable fields created ONCE (not in rebuildContent): the container (so its children aren't
|
|
126
|
+
// blown away each rebuild) and the filter Input (so its text/state survives rebuilds).
|
|
127
|
+
this.container = new Container();
|
|
128
|
+
this.filterInput = new Input();
|
|
129
|
+
this.filterInput.focused = true;
|
|
130
|
+
|
|
131
|
+
this.rebuildContent(tabs, EMPTY_FILTER);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Recompute fixedMaxVisible from current terminal height and maxTabItems (capped ≥3). */
|
|
135
|
+
private recomputeFixedMaxVisible(): void {
|
|
136
|
+
const rows = this.tui?.terminal?.rows ?? process.stdout.rows ?? 24;
|
|
137
|
+
const computed = rows - SETTINGS_DIALOG_OVERHEAD;
|
|
138
|
+
this.fixedMaxVisible = Math.min(this.maxTabItems, Math.max(3, computed));
|
|
139
|
+
// The picker can legitimately need more rows than the list (a model setting may have many
|
|
140
|
+
// presets while its tab has few items), so cap it independently — then reserve the larger of
|
|
141
|
+
// the two for the shared list/picker slot. Opening the dropdown then never grows the modal.
|
|
142
|
+
this.pickerMaxRows = Math.min(PICKER_VIEWPORT_MAX, Math.max(3, computed));
|
|
143
|
+
this.listAreaRows = Math.max(this.fixedMaxVisible, this.pickerMaxRows);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Refresh the cached tabs and the invariant description index. Called whenever fresh tabs
|
|
147
|
+
* are built (open, tab switch, value edit) — NOT per filter keystroke. */
|
|
148
|
+
private refreshTabs(tabs: TabDefinition[]): void {
|
|
149
|
+
this.cachedTabs = tabs;
|
|
150
|
+
this.descriptions.clear();
|
|
151
|
+
for (const tab of tabs) {
|
|
152
|
+
for (const item of tab.settings) {
|
|
153
|
+
const desc = this.getDescriptionFn(item.id) ?? item.description;
|
|
154
|
+
if (desc) {
|
|
155
|
+
this.descriptions.set(item.id, desc);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private rebuildContent(tabs: TabDefinition[], filterText: string): void {
|
|
162
|
+
this.contentBox = new Box(0, 0);
|
|
163
|
+
|
|
164
|
+
this.contentBox.addChild(new Text(this.renderTabBar(tabs), 0, 0));
|
|
165
|
+
this.contentBox.addChild(new Spacer(1));
|
|
166
|
+
|
|
167
|
+
// The active tab's full unfiltered items; filtered below into `visibleSettings`.
|
|
168
|
+
const rawSettings = tabs[this.activeTab]?.settings ?? [];
|
|
169
|
+
|
|
170
|
+
// Filter the cached items by a case-insensitive substring over label + current value.
|
|
171
|
+
const needle = filterText.toLowerCase();
|
|
172
|
+
const visibleSettings = needle
|
|
173
|
+
? rawSettings.filter((item) => {
|
|
174
|
+
const haystack = `${item.label} ${item.displayValue ?? item.value ?? ""}`.toLowerCase();
|
|
175
|
+
return haystack.includes(needle);
|
|
176
|
+
})
|
|
177
|
+
: rawSettings;
|
|
178
|
+
// The SettingsList is built from this filtered array, so its selectedIndex indexes it —
|
|
179
|
+
// store it for Up/Down resolution in handleInput.
|
|
180
|
+
this.currentVisibleItems = visibleSettings;
|
|
181
|
+
|
|
182
|
+
// Convert schema SettingItem to pi-tui SettingItem
|
|
183
|
+
const activeSettings: TuiSettingItem[] = visibleSettings.map((item): TuiSettingItem => {
|
|
184
|
+
const tuiItem: TuiSettingItem = {
|
|
185
|
+
id: item.id,
|
|
186
|
+
label: item.label,
|
|
187
|
+
currentValue: item.displayValue ?? item.value ?? "",
|
|
188
|
+
description: undefined, // rendered externally in fixed area
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Wire the ValuePicker submenu for settings with presets. All preset-backed
|
|
192
|
+
// settings (enum, timeout, number) now flow through presets pairs → ValuePicker,
|
|
193
|
+
// which already hides the misleading "Custom..." entry for closed-string-enums.
|
|
194
|
+
if (item.presets && item.type) {
|
|
195
|
+
const rawValue = item.value ?? "";
|
|
196
|
+
const itemPresets = item.presets;
|
|
197
|
+
const itemType = item.type;
|
|
198
|
+
tuiItem.submenu = (_currentValue, done) => {
|
|
199
|
+
return new ValuePickerSubmenu(
|
|
200
|
+
itemPresets,
|
|
201
|
+
rawValue,
|
|
202
|
+
itemType,
|
|
203
|
+
this.theme,
|
|
204
|
+
(selectedValue) => {
|
|
205
|
+
if (selectedValue !== undefined) {
|
|
206
|
+
this.onChange(item.id, selectedValue);
|
|
207
|
+
}
|
|
208
|
+
done(); // tell SettingsList submenu is closed; don't pass a value to avoid a second onChange with serialized string
|
|
209
|
+
},
|
|
210
|
+
this.pickerMaxRows,
|
|
211
|
+
);
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return tuiItem;
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
this.settingsList = new SettingsList(
|
|
219
|
+
activeSettings,
|
|
220
|
+
this.fixedMaxVisible,
|
|
221
|
+
getSettingsListTheme(),
|
|
222
|
+
// SettingsList fires this for inline value-cycling (settings without a submenu).
|
|
223
|
+
// This modal routes all edits through submenus (which call onChange directly above
|
|
224
|
+
// with the raw value, then invoke done() with no args), so this callback is unused.
|
|
225
|
+
(_id: string, _newValue: string) => {},
|
|
226
|
+
() => this.onClose(),
|
|
227
|
+
{ enableSearch: this.enableSearch },
|
|
228
|
+
);
|
|
229
|
+
this.contentBox.addChild(this.settingsList);
|
|
230
|
+
|
|
231
|
+
// Rebuild only the container's children (do NOT recreate `this.container`): re-add the
|
|
232
|
+
// persistent filter Input ABOVE the content box, then the content box. Since `container` is
|
|
233
|
+
// stable and `filterInput` is re-added (not recreated), filter text/state survives the
|
|
234
|
+
// per-keystroke / per-tab-switch rebuild. This is the only renderable home that survives.
|
|
235
|
+
this.container.clear();
|
|
236
|
+
this.container.addChild(this.filterInput);
|
|
237
|
+
this.container.addChild(this.contentBox);
|
|
238
|
+
|
|
239
|
+
this.selectedItem = visibleSettings[0] ?? null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private renderTabBar(tabs: TabDefinition[]): string {
|
|
243
|
+
const t = this.theme;
|
|
244
|
+
const parts: string[] = [" "];
|
|
245
|
+
for (let i = 0; i < tabs.length; i++) {
|
|
246
|
+
const isActive = i === this.activeTab;
|
|
247
|
+
const label = ` ${tabs[i].label} `;
|
|
248
|
+
if (isActive) {
|
|
249
|
+
parts.push(t.bg("selectedBg", t.fg("text", label)));
|
|
250
|
+
} else {
|
|
251
|
+
parts.push(t.fg("muted", label));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return parts.join("");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
switchTab(dir: number): void {
|
|
258
|
+
const tabs = this.buildTabsFn();
|
|
259
|
+
this.activeTab = (this.activeTab + dir + tabs.length) % tabs.length;
|
|
260
|
+
this.refreshTabs(tabs);
|
|
261
|
+
// Thread the live filter so it persists across tab switches (otherwise the filter silently
|
|
262
|
+
// clears on each switch).
|
|
263
|
+
this.rebuildContent(tabs, this.filterInput.getValue());
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
syncActiveTab(): void {
|
|
267
|
+
if (!this.settingsList) return;
|
|
268
|
+
const tabs = this.buildTabsFn();
|
|
269
|
+
this.refreshTabs(tabs); // refresh cachedTabs + descriptions so the next filter keystroke sees the edited values
|
|
270
|
+
const activeSettings = tabs[this.activeTab]?.settings ?? [];
|
|
271
|
+
for (const item of activeSettings) {
|
|
272
|
+
this.settingsList.updateValue(item.id, item.displayValue ?? item.value ?? "");
|
|
273
|
+
}
|
|
274
|
+
const idx = this.listInternals.selectedIndex ?? 0;
|
|
275
|
+
const items = this.listInternals.filteredItems;
|
|
276
|
+
this.selectedItem = items?.[idx] ?? activeSettings[idx] ?? null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
render(width: number): string[] {
|
|
280
|
+
const safeWidth = Math.max(1, width);
|
|
281
|
+
|
|
282
|
+
this.recomputeFixedMaxVisible();
|
|
283
|
+
if (this.settingsList) this.listInternals.maxVisible = this.fixedMaxVisible;
|
|
284
|
+
|
|
285
|
+
let lines: string[];
|
|
286
|
+
try {
|
|
287
|
+
lines = this.container.render(safeWidth);
|
|
288
|
+
} catch (error) {
|
|
289
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
290
|
+
return [this.theme.fg("error", `Settings render error: ${message}`)];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const preListLines = 3;
|
|
294
|
+
const hintLineCount = 2;
|
|
295
|
+
const descSpacer = 1;
|
|
296
|
+
const descLineCount = 3;
|
|
297
|
+
|
|
298
|
+
for (let i = 0; i < lines.length; i++) {
|
|
299
|
+
const line = lines[i];
|
|
300
|
+
if (line?.includes("Esc to cancel")) {
|
|
301
|
+
// Re-label the close hint, and drop the now-false "Space" change key (Space is a filter
|
|
302
|
+
// character in the filter-first model — only Enter opens the picker).
|
|
303
|
+
lines[i] = line
|
|
304
|
+
.replace("Enter/Space to change", "Enter to change")
|
|
305
|
+
.replace("Esc to cancel", "Esc to save for session and close");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Pad the list/picker slot to its reserved height BEFORE the description, so the description
|
|
310
|
+
// stays pinned at a fixed line whether the (shorter) list or the (taller) picker is rendering.
|
|
311
|
+
// Padding only at the end would let the description slide down when the dropdown opens.
|
|
312
|
+
const slotEnd = preListLines + this.listAreaRows + hintLineCount;
|
|
313
|
+
while (lines.length < slotEnd) {
|
|
314
|
+
lines.push("");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
lines.push("");
|
|
318
|
+
|
|
319
|
+
// Render the description for the selected item, kept visible even while a value-picker
|
|
320
|
+
// dropdown is open. Opening the dropdown does not move the selection, so this.selectedItem
|
|
321
|
+
// still points at the item that opened it — the user keeps context on what they are editing.
|
|
322
|
+
if (this.selectedItem) {
|
|
323
|
+
const desc = this.descriptions.get(this.selectedItem.id);
|
|
324
|
+
const wrappedDesc = desc ? wrapTextWithAnsi(desc, safeWidth - 4) : [];
|
|
325
|
+
for (let i = 0; i < descLineCount; i++) {
|
|
326
|
+
if (i < wrappedDesc.length) {
|
|
327
|
+
if (i === 2 && wrappedDesc.length > 3) {
|
|
328
|
+
const line = wrappedDesc[2];
|
|
329
|
+
lines.push(line.length > safeWidth - 4 ? `${line.slice(0, safeWidth - 4)}…` : line);
|
|
330
|
+
} else {
|
|
331
|
+
lines.push(wrappedDesc[i]);
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
lines.push("");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
for (let i = 0; i < descLineCount; i++) lines.push("");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const targetTotalLines = preListLines + this.listAreaRows + hintLineCount + descSpacer + descLineCount;
|
|
342
|
+
while (lines.length < targetTotalLines) {
|
|
343
|
+
lines.push("");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return lines;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
invalidate(): void {
|
|
350
|
+
this.container.invalidate();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
handleInput(data: string): void {
|
|
354
|
+
// State-aware routing: when the value-picker submenu is open, the modal does not intercept
|
|
355
|
+
// keys — everything is delegated to the list/picker so Tab/Left/Right never reach switchTab
|
|
356
|
+
// while picking.
|
|
357
|
+
if (this.settingsList && this.listInternals.submenuComponent) {
|
|
358
|
+
this.settingsList.handleInput(data);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Tab / Shift+Tab switch tabs. Verified feasible: pi-tui Input.handleInput does not consume
|
|
363
|
+
// Tab (only the Editor component does), and the settings overlay is a capturing modal.
|
|
364
|
+
if (matchesKey(data, Key.tab)) {
|
|
365
|
+
this.switchTab(1);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (matchesKey(data, Key.shift("tab"))) {
|
|
369
|
+
this.switchTab(-1);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Left/Right move the text cursor inside the filter field (reassigned from tab switching to
|
|
374
|
+
// filter-cursor movement, mirroring pi's /settings).
|
|
375
|
+
if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
|
|
376
|
+
this.filterInput.handleInput(data);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Arrow Up/Down move selection; Enter opens the value picker. These go to the SettingsList.
|
|
381
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.down) || matchesKey(data, Key.enter)) {
|
|
382
|
+
const prevIndex = this.settingsList ? this.listInternals.selectedIndex : -1;
|
|
383
|
+
this.settingsList?.handleInput(data);
|
|
384
|
+
const newIndex = this.settingsList ? this.listInternals.selectedIndex : -1;
|
|
385
|
+
if (newIndex !== prevIndex && this.settingsList) {
|
|
386
|
+
const item = this.currentVisibleItems[newIndex] ?? this.currentVisibleItems[0] ?? null;
|
|
387
|
+
if (item) {
|
|
388
|
+
this.selectedItem = item;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Esc / Ctrl+C close the modal (Ctrl+C mirrors pi-tui's select.cancel = [escape, ctrl+c];
|
|
395
|
+
// without this, Ctrl+C falls to the filter default-sink where Input intercepts it as cancel
|
|
396
|
+
// but onEscape is unset, so the modal would not close).
|
|
397
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
398
|
+
this.onClose();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Default sink: everything else (editing keys — Backspace, Delete, word-delete (Ctrl+W),
|
|
403
|
+
// line-clear (Ctrl+U) — and any other unhandled keystroke) feeds the filter field. Never route
|
|
404
|
+
// these to settingsList.handleInput, which would no-op them and silently break filter editing.
|
|
405
|
+
// Reuse the cached tabs (NOT buildTabsFn) — buildSchemaTabGroups must not re-run per keystroke.
|
|
406
|
+
// Only rebuild when the filter text actually changed: a no-op keystroke (Backspace on an empty
|
|
407
|
+
// filter, cursor movement) must not reset the list selection to item 0. Mirrors the value picker.
|
|
408
|
+
const before = this.filterInput.getValue();
|
|
409
|
+
this.filterInput.handleInput(data);
|
|
410
|
+
if (this.filterInput.getValue() !== before) {
|
|
411
|
+
this.rebuildContent(this.cachedTabs, this.filterInput.getValue());
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|