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,301 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * First-class setting types — the `TypeDefinition` registry.
6
+ *
7
+ * Each setting `type` is a registered `TypeDefinition` that owns its value↔string conversion
8
+ * and metadata. Seven built-ins ship; any extension can register more via
9
+ * `registerTypeDefinition` (same code path as the built-ins).
10
+ *
11
+ * Null/0/bounds/presets stay owned by `SettingSchema` + `presets` — a type only owns how a
12
+ * string becomes a value and back, plus a few metadata fields. The single normalization gate
13
+ * (of the design) consults `getTypeDefinition(type)` for parse/format/valueType/bounded.
14
+ */
15
+
16
+ import { getCapturedCtx } from "./ctx-store.js";
17
+ import { modelLabel, resolveModelPresets, splitModelId } from "./model-presets.js";
18
+ import type { PresetPair, PresetsSource } from "./schema.js";
19
+ import {
20
+ formatCompactThreshold,
21
+ formatHumanDuration,
22
+ isCompactThreshold,
23
+ parseCompactThreshold,
24
+ parseHumanDuration,
25
+ } from "./validation.js";
26
+
27
+ /** Per-call context handed to a `TypeDefinition`'s parse/format/prefill. */
28
+ export interface TypeContext {
29
+ /** Effective presets for this setting, as `[label, value]` pairs. */
30
+ presets: readonly PresetPair[];
31
+ /** Inclusive numeric floor (from `SettingSchema.min`). */
32
+ min?: number;
33
+ /** Inclusive numeric ceiling (from `SettingSchema.max`). */
34
+ max?: number;
35
+ }
36
+
37
+ /** A first-class setting type. `T` is the parsed/kept JS value type (defaults to `unknown`). */
38
+ export interface TypeDefinition<T = unknown> {
39
+ /** Type id ("number" | "duration" |... | <custom>); matches `SettingSchema.type`. */
40
+ id: string;
41
+ /** `typeof` of the parsed/kept value — drives the gate's step-5 type-match. */
42
+ valueType: "number" | "string" | "boolean";
43
+ /** string → value | invalid. Invalid MUST return `undefined`, never `null` (null is a preset value). */
44
+ parse(input: string, ctx: TypeContext): T | undefined;
45
+ /** value → display string. */
46
+ format(value: T, ctx: TypeContext): string;
47
+ /** Custom-input seed; defaults to `format` when absent. */
48
+ toInputPrefill?(value: T, ctx: TypeContext): string;
49
+ /** Can the user type free-form input for this type? */
50
+ supportsCustomValues: boolean;
51
+ /** Hint shown in the custom-value picker. */
52
+ errorMessage: string;
53
+ /** When true, the gate applies `min`/`max` bounds (number/duration). */
54
+ bounded?: boolean;
55
+ /**
56
+ * Optional DEFAULT presets for this type — overridden by `SettingSchema.presets` (replace, not
57
+ * merge) when the setting declares its own. Either level may be a static pair array (resolved
58
+ * at the gate) or a resolver function (resolved once at modal-open, e.g. a model list read
59
+ * from the registry). Absent means this type offers no default presets.
60
+ */
61
+ presets?: PresetsSource;
62
+ }
63
+
64
+ // ── Registry ────────────────────────────────────────────────────────────────
65
+
66
+ const registry = new Map<string, TypeDefinition>();
67
+
68
+ /**
69
+ * Register a setting type (built-ins are pre-registered at module load; extensions call this at
70
+ * startup for custom types). Stored as `TypeDefinition<unknown>` so heterogeneous definitions
71
+ * share one registry — the concrete `T` is an asserted shape, the same trust model as the gate.
72
+ */
73
+ export function registerTypeDefinition<T>(def: TypeDefinition<T>): void {
74
+ registry.set(def.id, def as TypeDefinition<unknown>);
75
+ }
76
+
77
+ /** Look up a setting type by id — the single lookup replacing every type-keyed switch. */
78
+ export function getTypeDefinition(typeId: string): TypeDefinition {
79
+ const def = registry.get(typeId);
80
+ if (!def) throw new Error(`Unknown setting type: ${typeId}`);
81
+ return def;
82
+ }
83
+
84
+ /**
85
+ * Coerce a serialized raw string to the type's JS value for `format`/`toInputPrefill`.
86
+ * Number-typed values are stored as numeric strings; this coerces them to a real number so
87
+ * `format(300000)` renders `"5m"` rather than failing. A non-numeric string (e.g. the null-label
88
+ * on a preset-less setting) falls back to the raw string. Used by schema-tabs (display) and
89
+ * value-picker (prefill) — the single coercion replacing their duplicated inline logic.
90
+ */
91
+ export function coerceTypedValue(rawValue: string, typeDef: TypeDefinition): string | number {
92
+ if (typeDef.valueType === "number") {
93
+ const num = Number(rawValue);
94
+ return Number.isNaN(num) ? rawValue : num;
95
+ }
96
+ return rawValue;
97
+ }
98
+
99
+ /**
100
+ * Format a serialized raw value for display via the TypeDefinition. Coerces the raw string to
101
+ * the type's JS value, then formats it. A non-numeric result (e.g. the null-label "Infinite" on
102
+ * a preset-less setting) is returned verbatim rather than fed through `format` (which would
103
+ * render "NaNs" for durations). The single display-format site shared by schema-tabs (list
104
+ * display) and value-picker (prefill) — keeps the NaN guard from drifting between them.
105
+ */
106
+ export function formatRawForDisplay(rawValue: string, typeDef: TypeDefinition, ctx: TypeContext): string {
107
+ // Run the type's format on the display path, else return the verbatim raw string.
108
+ // - number valueType: coerce to a number and format, but only when the raw is actually numeric —
109
+ // a non-numeric raw (e.g. the null-label 'Infinite') is returned verbatim so format() never
110
+ // receives a value it can't render.
111
+ // - string valueType: format the raw directly (e.g. the model type resolves provider/id → its
112
+ // display label). coerceTypedValue is an identity for string valueTypes, so the raw is the value.
113
+ // - boolean: returned verbatim (its 'true'/'false' raw already equals its format output).
114
+ if (typeDef.valueType === "number") {
115
+ const coerced = coerceTypedValue(rawValue, typeDef);
116
+ return typeof coerced === "number" ? typeDef.format(coerced, ctx) : rawValue;
117
+ }
118
+ return typeDef.valueType === "string" ? typeDef.format(rawValue, ctx) : rawValue;
119
+ }
120
+
121
+ // ── Built-in parse helpers ──────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Parse an integer (the `number` type's parser). Accepts ANY integer — including negatives and
125
+ * zero — and does NOT floor/clamp; out-of-range rejection is the gate's job (7, via `min`/`max`),
126
+ * not the parser's.
127
+ */
128
+ function parseInteger(input: string): number | undefined {
129
+ const trimmed = input.trim();
130
+ if (!/^-?\d+$/.test(trimmed)) return undefined;
131
+ return parseInt(trimmed, 10);
132
+ }
133
+
134
+ /**
135
+ * Parse a duration (the `duration` type's parser): human form via `parseHumanDuration`
136
+ * (e.g. "30s", "5m", "1d", "0s") with a bare-millisecond `Number(input)` fallback so a plain numeric
137
+ * string like "300000" still parses. Zero is a valid duration — range rejection (e.g. min > 0)
138
+ * is the gate's job (via `min`/`max`), not the parser's, matching the `number` type's contract.
139
+ * Negative/invalid → undefined.
140
+ */
141
+ function parseDuration(input: string): number | undefined {
142
+ if (input.trim() === "") return undefined;
143
+ const ms = parseHumanDuration(input);
144
+ if (ms !== undefined && ms >= 0) return ms;
145
+ const num = Number(input);
146
+ if (!Number.isNaN(num) && num >= 0) return num;
147
+ return undefined;
148
+ }
149
+
150
+ /**
151
+ * Match a preset by its label OR its raw value. Shared membership check — both the
152
+ * compact-threshold parser and the thinking-level parser validate input against the effective
153
+ * presets rather than keeping their own copy of the values.
154
+ */
155
+ function matchPresetValue(input: string, ctx: TypeContext): string | undefined {
156
+ for (const [label, value] of ctx.presets) {
157
+ if (label === input || String(value) === input) {
158
+ return value as string;
159
+ }
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ // ── The built-in TypeDefinitions ───────────────────────────────────────────
165
+
166
+ const numberType: TypeDefinition<number> = {
167
+ id: "number",
168
+ valueType: "number",
169
+ parse: (input) => parseInteger(input),
170
+ format: (value) => String(value),
171
+ supportsCustomValues: true,
172
+ errorMessage: "Enter a whole number",
173
+ bounded: true,
174
+ };
175
+
176
+ const durationType: TypeDefinition<number> = {
177
+ id: "duration",
178
+ valueType: "number",
179
+ parse: (input) => parseDuration(input),
180
+ format: (value) => formatHumanDuration(value),
181
+ supportsCustomValues: true,
182
+ errorMessage: "Enter a duration (e.g. 30s, 5m, 7d)",
183
+ bounded: true,
184
+ };
185
+
186
+ /** Canonical off/force sentinels for the compact-threshold type — the "never" and "always"
187
+ * states alongside the `compact>NK` "when exceeding" form. Identity-valued (label === value).
188
+ * Recognized by {@link compactThresholdType}'s parse so a consumer can declare them as bare preset
189
+ * strings (`presets: ["none", "compact", "compact>75K", ...]`). */
190
+ const COMPACT_THRESHOLD_SENTINELS = new Set(["none", "compact"]);
191
+
192
+ const compactThresholdType: TypeDefinition<string> = {
193
+ id: "compact-threshold",
194
+ valueType: "string",
195
+ parse: (input, ctx) => {
196
+ // 1. preset value (by label or raw value)
197
+ const preset = matchPresetValue(input, ctx);
198
+ if (preset !== undefined) return preset;
199
+ // 2. already-canonical compact>NK
200
+ if (isCompactThreshold(input)) return input;
201
+ // 3. canonical off/force sentinels (never / always)
202
+ if (COMPACT_THRESHOLD_SENTINELS.has(input)) return input;
203
+ // 4. numeric string → compact>NK
204
+ return parseCompactThreshold(input);
205
+ },
206
+ format: (value) => value,
207
+ toInputPrefill: (value) => formatCompactThreshold(value),
208
+ supportsCustomValues: true,
209
+ errorMessage: "Enter a compact threshold (e.g. 300)",
210
+ };
211
+
212
+ const stringType: TypeDefinition<string> = {
213
+ id: "string",
214
+ valueType: "string",
215
+ // Closed enum when effective presets exist: a value must match a preset's label OR raw value,
216
+ // else INVALID (the gate resets to default + logs). No presets → free-form identity (a value
217
+ // like baseBranch survives). Closed enums are ALSO enforced UI-side (supportsCustomValues: false).
218
+ parse: (input, ctx) => (ctx.presets.length > 0 ? matchPresetValue(input, ctx) : input),
219
+ format: (value) => value,
220
+ supportsCustomValues: false, // closed enums are enforced UI-side
221
+ errorMessage: "Select a value",
222
+ };
223
+
224
+ const booleanType: TypeDefinition<boolean> = {
225
+ id: "boolean",
226
+ valueType: "boolean",
227
+ parse: (input) => {
228
+ if (input === "true") return true;
229
+ if (input === "false") return false;
230
+ return undefined;
231
+ },
232
+ format: (value) => String(value),
233
+ supportsCustomValues: false,
234
+ errorMessage: "Select true or false",
235
+ };
236
+
237
+ // ── The thinking-level built-in ─────────────────────────────────────────────
238
+
239
+ /**
240
+ * The six thinking levels, as `[label, value]` preset pairs (label equals value). The default
241
+ * `presets` for the `thinking-level` type — a consumer writes only `{ type: "thinking-level" }`
242
+ * and gets these without declaring `presets` (overridable per-setting: replace, not merge).
243
+ */
244
+ export const THINKING_LEVEL_PRESETS = [
245
+ ["off", "off"],
246
+ ["minimal", "minimal"],
247
+ ["low", "low"],
248
+ ["medium", "medium"],
249
+ ["high", "high"],
250
+ ["xhigh", "xhigh"],
251
+ ] as const satisfies readonly PresetPair[];
252
+
253
+ const thinkingLevelType: TypeDefinition<string> = {
254
+ id: "thinking-level",
255
+ valueType: "string",
256
+ // Membership check against the EFFECTIVE presets (the type-def default, or a per-setting
257
+ // override). An unknown level returns undefined → the load gate rejects it and falls back to
258
+ // the setting's defaultValue.
259
+ parse: (input, ctx) => matchPresetValue(input, ctx),
260
+ format: (value) => value,
261
+ presets: THINKING_LEVEL_PRESETS,
262
+ supportsCustomValues: false,
263
+ errorMessage: "Select a thinking level",
264
+ };
265
+
266
+ // ── The model built-in ───────────────────────────────────────────────────────
267
+
268
+ /**
269
+ * Resolve a stored `"provider/id"` value to its display label via the captured registry. A stale
270
+ * id (one the registry has no record of) renders verbatim rather than failing. When no
271
+ * registry was captured, the raw value is returned unchanged (graceful — never dereferences an
272
+ * absent registry). The provider/id are split on the FIRST slash so an id containing a slash still
273
+ * resolves by its leading provider/id segments.
274
+ */
275
+ function formatModelValue(value: string): string {
276
+ const parts = splitModelId(value);
277
+ if (!parts) return value;
278
+ const found = getCapturedCtx().modelRegistry?.find(parts.provider, parts.id);
279
+ return found ? modelLabel(found) : value;
280
+ }
281
+
282
+ const modelType: TypeDefinition<string> = {
283
+ id: "model",
284
+ valueType: "string",
285
+ // Identity parse — a provider/id (even a stale one) loads unchanged. Null is valid via the
286
+ // gate's null-preset path (the resolver's ['Default', null] pair), not via parse.
287
+ parse: (input) => input,
288
+ format: (value) => formatModelValue(value),
289
+ presets: resolveModelPresets,
290
+ supportsCustomValues: false,
291
+ errorMessage: "Select a model",
292
+ };
293
+
294
+ // Pre-register the 7 built-ins at module load.
295
+ registerTypeDefinition(numberType);
296
+ registerTypeDefinition(durationType);
297
+ registerTypeDefinition(compactThresholdType);
298
+ registerTypeDefinition(stringType);
299
+ registerTypeDefinition(booleanType);
300
+ registerTypeDefinition(thinkingLevelType);
301
+ registerTypeDefinition(modelType);
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Simple modal frame renderer for pi TUI overlays.
6
+ *
7
+ * Provides title bar, side borders, and optional help text undertitle.
8
+ * Used internally by avtc-pi-settings-ui's openSettingsModal.
9
+ *
10
+ * Usage:
11
+ * ```ts
12
+ * const frame = new ModalFrame({
13
+ * title: "My Dialog",
14
+ * content: myContentComponent,
15
+ * helpText: "Esc: close | Enter: confirm",
16
+ * });
17
+ * ```
18
+ */
19
+ import type { Component } from "@earendil-works/pi-tui";
20
+
21
+ // ── Border characters ──────────────────────────────────────────────────────────
22
+
23
+ const ROUNDED = {
24
+ topLeft: "╭",
25
+ topRight: "╮",
26
+ bottomLeft: "╰",
27
+ bottomRight: "╯",
28
+ horizontal: "─",
29
+ vertical: "│",
30
+ } as const;
31
+
32
+ // ── Types ──────────────────────────────────────────────────────────────────────
33
+
34
+ export interface ModalFrameOptions {
35
+ /** Title text displayed in the top border. */
36
+ title: string;
37
+ /** Optional right-aligned subtitle in the top border. */
38
+ titleRight?: string;
39
+ /** Nested content component rendered inside the frame. */
40
+ content: Component;
41
+ /** Optional help text displayed below the bottom border. */
42
+ helpText?: string;
43
+ }
44
+
45
+ // ── ModalFrame ─────────────────────────────────────────────────────────────────
46
+
47
+ export class ModalFrame implements Component {
48
+ private options: ModalFrameOptions;
49
+
50
+ constructor(options: ModalFrameOptions) {
51
+ this.options = options;
52
+ }
53
+
54
+ invalidate(): void {
55
+ // ModalFrame has no internal state to invalidate
56
+ }
57
+
58
+ render(width: number): string[] {
59
+ const { title, titleRight, content, helpText } = this.options;
60
+ const b = ROUNDED;
61
+ const lines: string[] = [];
62
+
63
+ // ── Top border with title ──
64
+ const innerWidth = width - 2; // minus left and right border chars
65
+ const titleStr = title ? ` ${title} ` : "";
66
+ const titleRightStr = titleRight ? ` ${titleRight} ` : "";
67
+ const titleContentLen = titleStr.length + titleRightStr.length;
68
+ const fillLen = Math.max(0, innerWidth - titleContentLen);
69
+ const topLine = `${b.topLeft}${titleStr}${b.horizontal.repeat(fillLen)}${titleRightStr}${b.topRight}`;
70
+ // Pad/truncate to exact width
71
+ lines.push(padToWidth(topLine, width));
72
+
73
+ // ── Content ──
74
+ const contentWidth = Math.max(1, innerWidth);
75
+ const contentLines = content.render(contentWidth);
76
+ for (const line of contentLines) {
77
+ // Truncate content line to innerWidth, pad with spaces to fill
78
+ const inner = padToWidth(truncateToWidth(line, contentWidth), contentWidth);
79
+ lines.push(`${b.vertical}${inner}${b.vertical}`);
80
+ }
81
+
82
+ // ── Bottom border ──
83
+ lines.push(`${b.bottomLeft}${b.horizontal.repeat(innerWidth)}${b.bottomRight}`);
84
+
85
+ // ── Help text undertitle ──
86
+ if (helpText) {
87
+ lines.push(padToWidth(` ${helpText} `, width));
88
+ }
89
+
90
+ return lines;
91
+ }
92
+ }
93
+
94
+ // ── Helpers ────────────────────────────────────────────────────────────────────
95
+
96
+ /** Truncate a string to at most `maxWidth` visible (ANSI-aware) columns. */
97
+ function truncateToWidth(text: string, maxWidth: number): string {
98
+ // Strip ANSI escape sequences for width measurement
99
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: legitimate ANSI escape handling
100
+ const stripped = text.replace(/\x1b\[[0-9;]*m/g, "");
101
+ if (stripped.length <= maxWidth) return text;
102
+ // Find cut point in original string where visible chars reach maxWidth
103
+ let visible = 0;
104
+ let i = 0;
105
+ let inEscape = false;
106
+ while (i < text.length && visible < maxWidth) {
107
+ if (text[i] === "\x1b") {
108
+ inEscape = true;
109
+ i++;
110
+ continue;
111
+ }
112
+ if (inEscape) {
113
+ if (text[i] === "m") inEscape = false;
114
+ i++;
115
+ continue;
116
+ }
117
+ visible++;
118
+ i++;
119
+ }
120
+ return text.slice(0, i);
121
+ }
122
+
123
+ /** Pad a string with spaces to exactly `width` visible columns. */
124
+ function padToWidth(text: string, width: number): string {
125
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: legitimate ANSI escape handling
126
+ const stripped = text.replace(/\x1b\[[0-9;]*m/g, "");
127
+ const visibleLen = stripped.length;
128
+ if (visibleLen >= width) return text;
129
+ return text + " ".repeat(width - visibleLen);
130
+ }
@@ -0,0 +1,68 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Shared helpers for the picker submenus (enum-picker, value-picker).
6
+ *
7
+ * Both pickers use the same cursor up/down wrap-around navigation and the
8
+ * same line-building loop; extracting these keeps them in sync.
9
+ */
10
+
11
+ /** Move `index` by `delta` (−1 up, +1 down) within a list of `length` entries,
12
+ * wrapping at both ends. Returns the new index.
13
+ */
14
+ export function wrapIndex(index: number, length: number, delta: number): number {
15
+ if (length <= 0) return 0;
16
+ return (index + delta + length) % length;
17
+ }
18
+
19
+ /** Cursor step deltas for wrapIndex(): up = previous, down = next. */
20
+ const STEP_UP = -1;
21
+ const STEP_DOWN = 1;
22
+
23
+ /** Compute the visible window for a centered scrolling viewport (mirrors pi-tui's SelectList:
24
+ * the selection stays centered until it nears the top/bottom, then the window pins to that edge).
25
+ * Returns `{ start, end }` (half-open) clamped to `[0, length]`. When `length <= maxVisible` the
26
+ * whole list fits and `{ start: 0, end: length }` is returned. */
27
+ export function computeViewport(
28
+ selectedIndex: number,
29
+ length: number,
30
+ maxVisible: number,
31
+ ): { start: number; end: number } {
32
+ if (length <= 0) return { start: 0, end: 0 };
33
+ const start = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), length - maxVisible));
34
+ const end = Math.min(start + maxVisible, length);
35
+ return { start, end };
36
+ }
37
+
38
+ /** Build a single picker line: cursor prefix (or 2-space indent) + the themed label (selected vs unselected). */
39
+ export function renderPickerLine(
40
+ isSelected: boolean,
41
+ theme: { cursor: string },
42
+ selectedLabel: string,
43
+ unselectedLabel: string,
44
+ ): string {
45
+ const prefix = isSelected ? theme.cursor : " ";
46
+ const label = isSelected ? selectedLabel : unselectedLabel;
47
+ return prefix + label;
48
+ }
49
+
50
+ /** Keybinding shape used by handlePickerNavigation (subset of pi-tui KeybindingsManager). */
51
+ interface KeybindingMatcher {
52
+ matches(data: string, action: string): boolean;
53
+ }
54
+
55
+ /**
56
+ * Handle cursor up/down navigation for a picker. Returns the new wrapped index when an up/down key
57
+ * is matched, or `undefined` when the input is not a navigation key (caller handles confirm/cancel/etc).
58
+ */
59
+ export function handlePickerNavigation(
60
+ kb: KeybindingMatcher,
61
+ data: string,
62
+ selectedIndex: number,
63
+ length: number,
64
+ ): number | undefined {
65
+ if (kb.matches(data, "tui.select.up")) return wrapIndex(selectedIndex, length, STEP_UP);
66
+ if (kb.matches(data, "tui.select.down")) return wrapIndex(selectedIndex, length, STEP_DOWN);
67
+ return undefined;
68
+ }
@@ -0,0 +1,140 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Schema-driven tab group builder.
6
+ *
7
+ * Converts a SettingsSchema + current settings values into the TabDefinition[]
8
+ * format expected by SettingsTabsModal. Handles display formatting for all
9
+ * setting types (durations, compact thresholds, presetLabels mapping, etc).
10
+ */
11
+
12
+ import {
13
+ buildPresetItems,
14
+ buildPresetItemsFromPairs,
15
+ computeRawInternalValue,
16
+ resolveEffectivePresets,
17
+ resolveNullLabelFromPairs,
18
+ } from "../normalization.js";
19
+ import type { PresetItem, PresetPair, SettingItem, SettingSchema, SettingsSchema, TabDefinition } from "../schema.js";
20
+ import { formatRawForDisplay, getTypeDefinition, type TypeContext } from "../type-definitions.js";
21
+
22
+ /**
23
+ * Format a raw internal value for display in the settings list.
24
+ * Display formatting is `getTypeDefinition(type).format` — the old type-discriminant switch (whose
25
+ * `default` returned raw ms) is gone, so a `duration` value renders as `"30s"`, not `"30000"`.
26
+ * Preset-matched values (the `"Infinite"`/`"Never"` preset labels) are still resolved by the
27
+ * preset-label map at the top — unaffected.
28
+ */
29
+ function formatSettingDisplay(rawValue: string, settingType: string, presets: readonly PresetItem[]): string {
30
+ if (rawValue === "") return rawValue;
31
+
32
+ // Map a raw internal value to its preset label.
33
+ if (presets.length) {
34
+ const match = presets.find((p) => p.displayValue === rawValue);
35
+ if (match) return match.label;
36
+ }
37
+
38
+ const typeDef = getTypeDefinition(settingType);
39
+ // Build a minimal TypeContext (format rarely needs it; compact-threshold format is identity).
40
+ const ctx: TypeContext = { presets: presets.map((p): PresetPair => [p.label, p.rawValue]) };
41
+
42
+ // Format the raw value for display (coerce → typeof-number-guard → format). Shared with
43
+ // value-picker so the NaN guard can't drift between the two display paths.
44
+ return formatRawForDisplay(rawValue, typeDef, ctx);
45
+ }
46
+
47
+ /**
48
+ * Memoized cohesive preset list per setting schema.
49
+ * Schemas are immutable references, so WeakMap lets us compute the PresetItem[] once
50
+ * per schema instead of on every interactive rebuild (arrow-key nav, value change).
51
+ * Only STATIC-array effective presets are cached: resolved function-presets are fresh per
52
+ * modal-open (the live model list changes between opens), so they bypass this cache.
53
+ */
54
+ const presetsCache = new WeakMap<SettingSchema, readonly PresetItem[]>();
55
+
56
+ /**
57
+ * Build tab groups from a schema + current settings values.
58
+ * Returns TabDefinition[] compatible with SettingsTabsModal.
59
+ *
60
+ * `resolvedPairs` carries the function-presets resolved once at modal-open (the model
61
+ * resolver's live list). Pass `null` for callers without a modal (static-array presets
62
+ * render from the WeakMap cache; function-presets settings render with an empty list).
63
+ */
64
+ export function buildSchemaTabGroups(
65
+ settings: Record<string, unknown>,
66
+ schema: SettingsSchema,
67
+ resolvedPairs: Map<string, PresetPair[]> | null,
68
+ ): TabDefinition[] {
69
+ const pairsMap = resolvedPairs;
70
+
71
+ /**
72
+ * Preset items for a setting. Static-array sources read the WeakMap cache; function
73
+ * sources read the per-open resolved map fresh (never cached — the model list changes
74
+ * between opens). If a resolver returned no pairs (threw/rejected), the list is empty.
75
+ */
76
+ const getPresets = (
77
+ setting: SettingSchema,
78
+ eff: ReturnType<typeof resolveEffectivePresets>,
79
+ ): readonly PresetItem[] => {
80
+ if (eff.isFunction) {
81
+ const pairs = pairsMap?.get(setting.id) ?? [];
82
+ return buildPresetItemsFromPairs(pairs);
83
+ }
84
+ let cached = presetsCache.get(setting);
85
+ if (!cached) {
86
+ cached = buildPresetItems(setting);
87
+ presetsCache.set(setting, cached);
88
+ }
89
+ return cached;
90
+ };
91
+
92
+ const result: TabDefinition[] = [];
93
+ // Build O(1) lookup map for settings by id
94
+ const settingMap = new Map(schema.settings.map((s) => [s.id, s]));
95
+
96
+ for (const tabDef of schema.tabs) {
97
+ const items: SettingItem[] = [];
98
+
99
+ for (const settingId of tabDef.settingIds) {
100
+ const settingSchema = settingMap.get(settingId);
101
+ if (!settingSchema) continue;
102
+
103
+ const eff = resolveEffectivePresets(settingSchema);
104
+ const presetItems = getPresets(settingSchema, eff);
105
+ let rawValue = computeRawInternalValue(settings, settingSchema);
106
+
107
+ // A function-presets setting derives its null label from the resolved pairs (e.g. the
108
+ // model resolver's ["Default", null]): when the value is null/undefined (the static
109
+ // "Infinite" fallback), override it with the resolved null label so a null model shows
110
+ // "Default". Checks the structural value (not the display string) so a literal "Infinite"
111
+ // string value is never mistaken for a null.
112
+ if (eff.isFunction) {
113
+ const original = settings[settingSchema.id];
114
+ if ((original === null || original === undefined) && pairsMap?.has(settingSchema.id)) {
115
+ const resolvedNull = resolveNullLabelFromPairs(pairsMap.get(settingSchema.id) ?? []);
116
+ if (resolvedNull) rawValue = resolvedNull;
117
+ }
118
+ }
119
+
120
+ const displayValue = formatSettingDisplay(rawValue, settingSchema.type, presetItems);
121
+
122
+ items.push({
123
+ id: settingSchema.id,
124
+ label: settingSchema.label,
125
+ value: rawValue,
126
+ displayValue,
127
+ type: settingSchema.type,
128
+ description: settingSchema.description,
129
+ presets: presetItems,
130
+ });
131
+ }
132
+
133
+ result.push({
134
+ label: tabDef.label,
135
+ settings: items,
136
+ });
137
+ }
138
+
139
+ return result;
140
+ }