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,353 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * ValuePickerSubmenu — filter-first preset value picker.
6
+ *
7
+ * A persistent top filter field plus a filtered list of presets. A pinned "Custom value…" row
8
+ * (open types only) switches the top field into a text-entry state. The current value absent
9
+ * from presets surfaces as a pinned custom-indicator. Model entries mute their `[provider]` span
10
+ * via pi's Theme. Tab/Shift+Tab are inert (no tab-switching while picking).
11
+ */
12
+
13
+ import { getSettingsListTheme, type Theme } from "@earendil-works/pi-coding-agent";
14
+ import type { Component } from "@earendil-works/pi-tui";
15
+ import { getKeybindings, Input, Key, matchesKey } from "@earendil-works/pi-tui";
16
+ import { validateCustomValue } from "../custom-validation.js";
17
+ import { splitModelId } from "../model-presets.js";
18
+ import type { PresetItem, PresetPair, SettingType } from "../schema.js";
19
+ import { formatRawForDisplay, getTypeDefinition, type TypeContext, type TypeDefinition } from "../type-definitions.js";
20
+ import { computeViewport, handlePickerNavigation, renderPickerLine } from "./picker-helpers.js";
21
+
22
+ /** Theme label bold flag */
23
+ const BOLD = true;
24
+ /** Theme value non-bold flag */
25
+ const NOT_BOLD = false;
26
+
27
+ /** Default picker viewport cap (matches pi's native /settings visible-row count). The modal
28
+ * passes a terminal-aware `maxVisible` so the dropdown never grows the reserved slot. */
29
+ export const PICKER_VIEWPORT_MAX = 10;
30
+
31
+ /** Entry in the value picker: display label paired with the raw typed value. */
32
+ interface PickerEntry {
33
+ label: string;
34
+ rawValue: string | number | boolean | null;
35
+ kind: "preset" | "custom-indicator" | "custom";
36
+ }
37
+
38
+ export class ValuePickerSubmenu implements Component {
39
+ /** Top-field state: filter mode (keystrokes filter) or text-entry mode (keystrokes build a
40
+ * custom value, entered via the pinned "Custom value…" row). */
41
+ private mode: "filter" | "text-entry" = "filter";
42
+ private selectedIndex = 0;
43
+ private filterInput: Input;
44
+ private errorMessage = "";
45
+ private closed = false;
46
+ /** Snapshot of the filter text captured on entering text-entry mode; restored on Esc so the
47
+ * filtered list does not collapse to an empty filter. Cleared on leaving text-entry mode. */
48
+ private customEntryFilterSnapshot = "";
49
+
50
+ /** The SettingsListTheme used for row rendering (cursor/label/value/hint/description), fetched
51
+ * via the global getter — NOT a constructor param. The pi `Theme` constructor param is used
52
+ * only for `fg("muted", ...)` model-span muting. */
53
+ private readonly listTheme = getSettingsListTheme();
54
+ /** True iff this picker is for a `model`-type setting (constructor-time fact — muting applies). */
55
+ private readonly isModel: boolean;
56
+
57
+ private readonly entries: PickerEntry[];
58
+ private readonly typeDef: TypeDefinition;
59
+ private readonly ctx: TypeContext;
60
+
61
+ /** Max rows the picker viewport ever shows. The owning modal passes a value sized to its
62
+ * reserved slot so opening the dropdown cannot change the modal's height. Required (no
63
+ * default) per the no-optional-params lint — callers pass PICKER_VIEWPORT_MAX explicitly. */
64
+ private readonly maxVisible: number;
65
+
66
+ constructor(
67
+ presets: readonly PresetItem[],
68
+ currentRawValue: string,
69
+ private settingType: SettingType,
70
+ private theme: Theme,
71
+ private done: (value?: unknown) => void,
72
+ maxVisible: number,
73
+ ) {
74
+ this.maxVisible = maxVisible;
75
+ this.filterInput = new Input();
76
+ this.typeDef = getTypeDefinition(settingType);
77
+ this.isModel = settingType === "model";
78
+ // Build a TypeContext from presets (a custom parse/format derives the null label itself from
79
+ // ctx.presets — the pair whose value is null).
80
+ this.ctx = { presets: presets.map((p): PresetPair => [p.label, p.rawValue]) };
81
+
82
+ // Build entries: label for display, raw value for done callback.
83
+ // currentRawValue (serialized string) is matched against preset displayValue to find the active preset.
84
+ const matchIndex = presets.findIndex((p) => p.displayValue === currentRawValue);
85
+ const hasCustomIndicator = matchIndex < 0;
86
+
87
+ this.entries = [];
88
+ if (hasCustomIndicator) {
89
+ this.entries.push({
90
+ label: `◆ ${this.formatForDisplay(currentRawValue)}`,
91
+ rawValue: currentRawValue,
92
+ kind: "custom-indicator",
93
+ });
94
+ }
95
+ for (const preset of presets) {
96
+ this.entries.push({ label: preset.label, rawValue: preset.rawValue, kind: "preset" });
97
+ }
98
+ // Pinned "Custom value…" row (open types only): free-form entry via Up/Down + Enter, which
99
+ // switches the top field into text-entry mode. Distinguished by kind (rawValue is null, which
100
+ // is a valid preset value).
101
+ if (this.typeDef.supportsCustomValues) {
102
+ this.entries.push({ label: "Custom value…", rawValue: null, kind: "custom" });
103
+ }
104
+
105
+ // Initialize selection: the custom-indicator if present (index 0), else the active preset —
106
+ // offset by the pinned rows that sit above the filtered presets (reuses the same offset the
107
+ // filter-change reset uses, so the two stay in sync).
108
+ if (hasCustomIndicator) {
109
+ this.selectedIndex = 0;
110
+ } else {
111
+ this.selectedIndex = this.presetOffset + matchIndex;
112
+ }
113
+
114
+ // The top field is the filter; in text-entry mode it builds a custom value.
115
+ this.filterInput.focused = true;
116
+ }
117
+
118
+ /** Format a serialized raw value for display via the TypeDefinition. */
119
+ private formatForDisplay(rawValue: string): string {
120
+ if (rawValue === "") return "";
121
+ // Shared coerce → typeof-number-guard → format (type-definitions.formatRawForDisplay):
122
+ // a non-numeric label (e.g. a defensive null's "Infinite") is returned verbatim rather than
123
+ // fed through format (which would render "NaNs").
124
+ return formatRawForDisplay(rawValue, this.typeDef, this.ctx);
125
+ }
126
+
127
+ /** The custom-indicator entry (pinned above the filtered list, exempt from the filter), if any. */
128
+ private get customIndicator(): PickerEntry | undefined {
129
+ return this.entries.find((e) => e.kind === "custom-indicator");
130
+ }
131
+
132
+ /** The pinned "Custom value…" entry (open types only), exempt from the filter. */
133
+ private get customRow(): PickerEntry | undefined {
134
+ return this.entries.find((e) => e.kind === "custom");
135
+ }
136
+
137
+ /** Presets filtered by a case-insensitive substring over label OR serialized raw value. */
138
+ private get filteredPresets(): PickerEntry[] {
139
+ const needle = this.filterInput.getValue().toLowerCase();
140
+ const presets = this.entries.filter((e) => e.kind === "preset");
141
+ if (!needle) return presets;
142
+ return presets.filter((e) => {
143
+ const haystack = `${e.label} ${String(e.rawValue)}`.toLowerCase();
144
+ return haystack.includes(needle);
145
+ });
146
+ }
147
+
148
+ /** The full navigable list (order matters — selectedIndex indexes this): the pinned rows
149
+ * (custom-indicator AND "Custom value…") sit ABOVE the filtered presets (both exempt from the
150
+ * filter), then the filtered presets. */
151
+ private get visibleEntries(): PickerEntry[] {
152
+ const result: PickerEntry[] = [];
153
+ const indicator = this.customIndicator;
154
+ if (indicator) result.push(indicator);
155
+ const row = this.customRow;
156
+ if (row) result.push(row);
157
+ result.push(...this.filteredPresets);
158
+ return result;
159
+ }
160
+
161
+ /** Offset of the first filtered preset within visibleEntries (the count of pinned rows above).
162
+ * Used to map a preset's match index onto its visibleEntries position. */
163
+ private get presetOffset(): number {
164
+ let offset = 0;
165
+ if (this.customIndicator) offset++;
166
+ if (this.customRow) offset++;
167
+ return offset;
168
+ }
169
+
170
+ /** Focus the first matching PRESET (the pinned rows sit above the filtered list but are not
171
+ * matches), or fall back to the top pinned row when nothing matches. */
172
+ private focusFirstMatch(): void {
173
+ this.selectedIndex = this.filteredPresets.length > 0 ? this.presetOffset : 0;
174
+ }
175
+
176
+ render(width: number): string[] {
177
+ if (this.mode === "text-entry") {
178
+ return this.renderTextEntry(width);
179
+ }
180
+ return this.renderFilter(width);
181
+ }
182
+
183
+ private renderFilter(width: number): string[] {
184
+ const lines: string[] = [];
185
+ lines.push(...this.filterInput.render(width));
186
+
187
+ const entries = this.visibleEntries;
188
+ const { start: visibleStart, end: visibleEnd } = computeViewport(
189
+ this.selectedIndex,
190
+ entries.length,
191
+ this.maxVisible,
192
+ );
193
+
194
+ if (entries.length === 0) {
195
+ lines.push(this.listTheme.hint(" No matches"));
196
+ return lines;
197
+ }
198
+
199
+ for (let i = visibleStart; i < visibleEnd; i++) {
200
+ const entry = entries[i];
201
+ if (!entry) continue;
202
+ lines.push(this.renderEntry(i === this.selectedIndex, entry));
203
+ }
204
+
205
+ // Inline position indicator on the hint line (mirrors pi-tui SelectList's `(n/N)`), kept on the
206
+ // existing hint row so it never adds a line — the modal's reserved slot stays exact and the
207
+ // dropdown opening cannot change the modal height.
208
+ const scrolling = visibleStart > 0 || visibleEnd < entries.length;
209
+ const hint = scrolling
210
+ ? ` Enter to select · Esc to go back · (${this.selectedIndex + 1}/${entries.length})`
211
+ : " Enter to select · Esc to go back";
212
+ lines.push(this.listTheme.hint(hint));
213
+ return lines;
214
+ }
215
+
216
+ /** Render a single entry line with the cursor prefix, applying muted model-span theming
217
+ * (only to actual model presets — the custom-indicator renders its label verbatim). */
218
+ private renderEntry(isSelected: boolean, entry: PickerEntry): string {
219
+ if (this.isModel && entry.kind === "preset" && typeof entry.rawValue === "string" && entry.rawValue.includes("/")) {
220
+ // Model entry: isolate the provider from the VALUE (before the first '/'), not the label.
221
+ const parts = splitModelId(entry.rawValue);
222
+ const badge = this.theme.fg("muted", ` [${parts?.provider ?? ""}]`);
223
+ // No outer space — the space is the first char inside the muted badge (mirrors pi).
224
+ const idText = parts?.id ?? entry.rawValue;
225
+ const composedSelected = this.listTheme.label(idText, BOLD) + badge;
226
+ const composedUnselected = this.listTheme.value(idText, NOT_BOLD) + badge;
227
+ return renderPickerLine(isSelected, this.listTheme, composedSelected, composedUnselected);
228
+ }
229
+ return renderPickerLine(
230
+ isSelected,
231
+ this.listTheme,
232
+ this.listTheme.label(entry.label, BOLD),
233
+ this.listTheme.value(entry.label, NOT_BOLD),
234
+ );
235
+ }
236
+
237
+ private renderTextEntry(width: number): string[] {
238
+ const lines: string[] = [];
239
+ lines.push(this.listTheme.hint("custom:"));
240
+ lines.push(...this.filterInput.render(width));
241
+ if (this.errorMessage) {
242
+ lines.push(this.listTheme.description(this.errorMessage));
243
+ }
244
+ return lines;
245
+ }
246
+
247
+ handleInput(data: string): void {
248
+ if (this.closed) return;
249
+
250
+ if (this.mode === "text-entry") {
251
+ this.handleTextEntryInput(data);
252
+ return;
253
+ }
254
+ this.handleFilterInput(data);
255
+ }
256
+
257
+ private handleFilterInput(data: string): void {
258
+ const kb = getKeybindings();
259
+
260
+ // Up/Down move within the filtered set (+pinned rows).
261
+ const entries = this.visibleEntries;
262
+ const navIndex = handlePickerNavigation(kb, data, this.selectedIndex, entries.length);
263
+ if (navIndex !== undefined) {
264
+ this.selectedIndex = navIndex;
265
+ return;
266
+ }
267
+
268
+ // Enter selects the focused entry (preset/indicator) or activates text-entry (Custom row).
269
+ if (kb.matches(data, "tui.select.confirm") || data === "\r" || data === "\n") {
270
+ this.activateEntry();
271
+ return;
272
+ }
273
+
274
+ // Esc cancels (returns to the main list).
275
+ if (kb.matches(data, "tui.select.cancel")) {
276
+ this.closed = true;
277
+ this.done();
278
+ return;
279
+ }
280
+
281
+ // Tab / Shift+Tab are inert inside the picker (no tab-switching while picking).
282
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab"))) {
283
+ return;
284
+ }
285
+
286
+ // Left/Right move the filter cursor; everything else (printables + editing keys —
287
+ // Backspace/Delete/word-delete/line-clear) feeds the filter field (default sink). When the
288
+ // filter text changes, focus the first matching PRESET (the pinned rows sit above the filtered
289
+ // list but are not matches) — or index 0 (the top pinned row) when nothing matches. Cursor
290
+ // movement that leaves the text intact must not move the selection.
291
+ const before = this.filterInput.getValue();
292
+ this.filterInput.handleInput(data);
293
+ if (this.filterInput.getValue() !== before) {
294
+ this.focusFirstMatch();
295
+ }
296
+ }
297
+
298
+ private handleTextEntryInput(data: string): void {
299
+ const kb = getKeybindings();
300
+ if (kb.matches(data, "tui.select.confirm") || data === "\r" || data === "\n") {
301
+ this.submitCustomValue();
302
+ return;
303
+ }
304
+ if (kb.matches(data, "tui.select.cancel")) {
305
+ this.leaveTextEntry();
306
+ return;
307
+ }
308
+ if (this.errorMessage) this.errorMessage = "";
309
+ this.filterInput.handleInput(data);
310
+ }
311
+
312
+ /** Enter on the focused entry: preset/indicator → done(rawValue); Custom row → text-entry mode. */
313
+ private activateEntry(): void {
314
+ const entry = this.visibleEntries[this.selectedIndex];
315
+ if (!entry) return;
316
+ if (entry.kind === "custom") {
317
+ this.enterTextEntry();
318
+ return;
319
+ }
320
+ this.closed = true;
321
+ this.done(entry.rawValue);
322
+ }
323
+
324
+ private enterTextEntry(): void {
325
+ this.customEntryFilterSnapshot = this.filterInput.getValue();
326
+ this.filterInput.setValue("");
327
+ this.mode = "text-entry";
328
+ this.errorMessage = "";
329
+ }
330
+
331
+ private leaveTextEntry(): void {
332
+ this.filterInput.setValue(this.customEntryFilterSnapshot);
333
+ this.customEntryFilterSnapshot = "";
334
+ this.mode = "filter";
335
+ this.errorMessage = "";
336
+ this.focusFirstMatch();
337
+ }
338
+
339
+ /** Validate the typed custom value; on success fire done(rawValue), on failure show the error. */
340
+ private submitCustomValue(): void {
341
+ const result = validateCustomValue(this.filterInput.getValue(), this.settingType, this.ctx);
342
+ if (result === undefined) {
343
+ this.errorMessage = ` \u26a0 ${this.typeDef.errorMessage}`;
344
+ return;
345
+ }
346
+ this.closed = true;
347
+ this.done(result);
348
+ }
349
+
350
+ invalidate(): void {
351
+ this.filterInput.invalidate();
352
+ }
353
+ }
@@ -0,0 +1,107 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Validation helpers for settings values.
6
+ *
7
+ * Generic parsing/formatting primitives (durations, compact thresholds) consumed by the
8
+ * built-in `TypeDefinition`s (type-definitions.ts) and re-exported for legacy direct use.
9
+ */
10
+
11
+ // ── Duration helpers ─────────────────────────────────────────────────────────
12
+
13
+ /** Parse human-readable duration (e.g. "5m", "90s", "500ms") to milliseconds. Returns undefined for invalid/zero/negative. (invalid → undefined, never null — null is a preset value owned by the gate's null-label step) */
14
+ export function parseHumanDuration(input: string): number | undefined {
15
+ const trimmed = input.trim();
16
+ const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/);
17
+ if (!match) return undefined;
18
+ const num = parseFloat(match[1]);
19
+ const unit = match[2];
20
+ let ms: number;
21
+ switch (unit) {
22
+ case "ms":
23
+ ms = num;
24
+ break;
25
+ case "s":
26
+ ms = Math.round(num * 1000);
27
+ break;
28
+ case "m":
29
+ ms = Math.round(num * 60_000);
30
+ break;
31
+ case "h":
32
+ ms = Math.round(num * 3_600_000);
33
+ break;
34
+ case "d":
35
+ ms = Math.round(num * 86_400_000);
36
+ break;
37
+ default:
38
+ return undefined;
39
+ }
40
+ return ms;
41
+ }
42
+
43
+ /** Format milliseconds as a human-readable duration string (e.g. "5m", "90s", "500ms", "Infinite").
44
+ * Uses the largest unit where value >= 1, with clean fractions (0 or.5) for minutes/hours.
45
+ * Ensures parseHumanDuration(formatHumanDuration(ms)) round-trips correctly.
46
+ */
47
+ export function formatHumanDuration(ms: number | null): string {
48
+ if (ms === null) return "Infinite";
49
+ if (ms < 1000) return `${ms}ms`;
50
+ // Days tier (>= 1 day) — only kanban reaches here (1d/7d/30d). Non-integer days fall
51
+ // through to the hours/minutes logic below so they still round-trip via parseHumanDuration.
52
+ if (ms >= 86_400_000) {
53
+ const days = ms / 86_400_000;
54
+ const roundedDays = Math.round(days);
55
+ if (Math.abs(days - roundedDays) < 0.00001) return `${roundedDays}d`;
56
+ }
57
+ const seconds = ms / 1000;
58
+ if (seconds < 60) return `${Math.floor(seconds)}s`;
59
+ const minutes = seconds / 60;
60
+ const roundedMinutes = Math.round(minutes);
61
+ const mfrac = minutes - Math.floor(minutes);
62
+ // Check for half-minute fractions (e.g., 1.5m) — only for < 60 minutes
63
+ if (roundedMinutes < 60 && Math.abs(mfrac - 0.5) < 0.00001) return `${Math.floor(minutes) + 0.5}m`;
64
+ // Check for exact integer hours (e.g., 60m = 1h, 120m = 2h)
65
+ if (roundedMinutes >= 60) {
66
+ const hours = minutes / 60;
67
+ const roundedHours = Math.round(hours);
68
+ const hfrac = hours - Math.floor(hours);
69
+ if (Math.abs(hfrac) < 0.00001 || Math.abs(hfrac - 1) < 0.00001) return `${roundedHours}h`;
70
+ // Non-integer hours: show as minutes
71
+ return `${roundedMinutes}m`;
72
+ }
73
+ // Exact integer minutes
74
+ if (Math.abs(mfrac) < 0.00001 || Math.abs(mfrac - 1) < 0.00001) return `${roundedMinutes}m`;
75
+ // Non-integer, non-half minutes: show as seconds
76
+ return `${Math.floor(seconds)}s`;
77
+ }
78
+
79
+ // ── Compact threshold helpers ────────────────────────────────────────────────
80
+
81
+ /** Regex matching compact threshold values like "compact>75K". First digit must be 1-9 (no leading zero). */
82
+ const COMPACT_THRESHOLD_REGEX = /^compact>([1-9]\d*)K$/;
83
+
84
+ /** Parse a compact threshold number input (e.g. "300") to internal format ("compact>300K"). Returns undefined for invalid. (invalid → undefined, never null) */
85
+ export function parseCompactThreshold(input: string): string | undefined {
86
+ const num = parseInt(input, 10);
87
+ if (Number.isNaN(num) || num < 1) return undefined;
88
+ return `compact>${num}K`;
89
+ }
90
+
91
+ /** Format compact threshold internal value to pre-fill string. Returns "" for non-threshold values. */
92
+ export function formatCompactThreshold(value: string): string {
93
+ const match = value.match(COMPACT_THRESHOLD_REGEX);
94
+ return match ? match[1] : "";
95
+ }
96
+
97
+ /** Is `value` a canonical `compact>NK` threshold string? Single source for the format contract. */
98
+ export function isCompactThreshold(value: string): boolean {
99
+ return COMPACT_THRESHOLD_REGEX.test(value);
100
+ }
101
+
102
+ // ── Duration/compact PRIMITIVES (parse/format) live here; the TypeDefinition registry sits in
103
+ // type-definitions.ts, and the custom-value VALIDATION that USES the registry lives in
104
+ // custom-validation.ts. This keeps the dependency graph acyclic:
105
+ // custom-validation → type-definitions → (this module's primitives). ────────────────────────
106
+
107
+ // (parseHumanDuration / formatHumanDuration / parseCompactThreshold / formatCompactThreshold are exported above)