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.
@@ -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
+ }