avtc-pi-settings-ui 1.0.8 → 1.0.9
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/README.md +1 -1
- package/package.json +2 -2
- package/src/normalization.ts +22 -7
- package/src/schema.ts +9 -0
- package/src/type-definitions.ts +19 -6
- package/src/ui/schema-tabs.ts +2 -0
- package/src/ui/settings-modal.ts +2 -0
- package/src/ui/value-picker.ts +40 -10
package/README.md
CHANGED
|
@@ -102,7 +102,7 @@ Every setting has a `type` resolved through a first-class `TypeDefinition` regis
|
|
|
102
102
|
|
|
103
103
|
| Type | Stored value | Notes |
|
|
104
104
|
|------|-------------|-------|
|
|
105
|
-
| `string` | `string` | Text; closed enums via presets (`["a", "b"]`) |
|
|
105
|
+
| `string` | `string` | Text; closed enums via presets (`["a", "b"]`), or free-form with `supportsCustomValues: true` |
|
|
106
106
|
| `number` | `number` \| `null` | Optional `min`/`max` bounds; `null` (unbounded/off) via a null preset |
|
|
107
107
|
| `duration` | `number` \| `null` | Human-readable (`5m`, `1h`, `30s`, `14d`); stored as milliseconds. `null` = unbounded/off, via a null preset |
|
|
108
108
|
| `compact-threshold` | `string` | Compact-context thresholds (`none`, `compact`, `compact>75K`, …) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "avtc-pi-settings-ui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "A schema-driven settings UI for pi extensions — define your settings, get a tabbed modal, a /<name>:settings command, and layered persistence.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"avtc-pi-logger": "^1.0.3",
|
|
27
|
-
"minimatch": "^10.
|
|
27
|
+
"minimatch": "^10.2.6"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@biomejs/biome": "^2.5.0",
|
package/src/normalization.ts
CHANGED
|
@@ -94,16 +94,29 @@ export function resolveSettingValue(raw: Record<string, unknown>, setting: Setti
|
|
|
94
94
|
|
|
95
95
|
const typeDef = getTypeDefinition(setting.type);
|
|
96
96
|
|
|
97
|
+
// 3b. Empty string on a string-typed setting WITHOUT a null pair → null (unset). The null
|
|
98
|
+
// display for preset-less strings IS the empty string, so this is what makes that
|
|
99
|
+
// display round-trip: re-applying it unsets rather than storing a literal "".
|
|
100
|
+
if (value === "" && typeDef.valueType === "string" && nullLabel === undefined) {
|
|
101
|
+
value = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
97
104
|
// 4. null validity — keep null iff a null-preset exists (nullLabel derived from the effective
|
|
98
|
-
// presets)
|
|
105
|
+
// presets) OR the type is inherently nullable (a resolver-function presets source — e.g.
|
|
106
|
+
// the model list — only yields its null pair at modal-open, so the write gate would
|
|
107
|
+
// otherwise reject null and the setting could never be unset back to Default). A type
|
|
108
|
+
// with no null-preset and no nullable trait rejects null. Legitimate nulls never reach parse.
|
|
99
109
|
if (value === null) {
|
|
100
|
-
return nullLabel !== undefined ? null : INVALID;
|
|
110
|
+
return nullLabel !== undefined || typeDef.nullable === true ? null : INVALID;
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
// 5. parse-if-string. Parse is string-only by signature; raw numbers/booleans
|
|
104
|
-
// never enter it.
|
|
114
|
+
// never enter it. An open-value override (supportsCustomValues: true) parses with the
|
|
115
|
+
// presets stripped — preset matching is a UI affordance there, not a validity gate.
|
|
105
116
|
if (typeof value === "string") {
|
|
106
|
-
const
|
|
117
|
+
const openCtx: TypeContext = { presets: [], min: setting.min, max: setting.max };
|
|
118
|
+
const ctx = setting.supportsCustomValues === true ? openCtx : buildTypeContext(setting);
|
|
119
|
+
const p = typeDef.parse(value, ctx);
|
|
107
120
|
if (p === undefined) return INVALID;
|
|
108
121
|
value = p;
|
|
109
122
|
}
|
|
@@ -275,9 +288,11 @@ export function getEffectiveValue(raw: Record<string, unknown>, setting: Setting
|
|
|
275
288
|
export function computeRawInternalValue(settings: Record<string, unknown>, setting: SettingSchema): string {
|
|
276
289
|
const value = settings[setting.id];
|
|
277
290
|
if (value === null || value === undefined) {
|
|
278
|
-
//
|
|
279
|
-
// for
|
|
280
|
-
|
|
291
|
+
// Null display: the null-preset label when the setting has one, else a per-type fallback —
|
|
292
|
+
// "Infinite" for numeric/timeout-like values (null = no limit), EMPTY for strings (null =
|
|
293
|
+
// unset; a bare "Infinite" label is a number-ism that reads as a bogus string value).
|
|
294
|
+
if (resolveNullLabel(setting) !== undefined) return resolveNullLabel(setting) as string;
|
|
295
|
+
return getTypeDefinition(setting.type).valueType === "string" ? "" : "Infinite";
|
|
281
296
|
}
|
|
282
297
|
return String(value);
|
|
283
298
|
}
|
package/src/schema.ts
CHANGED
|
@@ -61,6 +61,12 @@ export interface SettingSchema<T = unknown> {
|
|
|
61
61
|
* atomic pass.
|
|
62
62
|
*/
|
|
63
63
|
presets?: PresetsSource;
|
|
64
|
+
/** Open-value override for this setting. `true` = the setting accepts arbitrary values:
|
|
65
|
+
* the value picker offers a free-form "Custom value…" row and the gate's parse treats the
|
|
66
|
+
* raw value as open (preset matching not enforced). `false`/absent = the type-def's
|
|
67
|
+
* `supportsCustomValues` governs (string enums stay closed). The type's base parse still
|
|
68
|
+
* applies — e.g. a `number` setting parses integers regardless of this flag. */
|
|
69
|
+
supportsCustomValues?: boolean;
|
|
64
70
|
/** Backward-compat field names (old keys that map to this setting's canonical id). */
|
|
65
71
|
aliases?: string[];
|
|
66
72
|
/** Legacy value aliases: old value → current raw value. Resolved before value-based matching. */
|
|
@@ -112,6 +118,9 @@ export interface SettingItem {
|
|
|
112
118
|
description?: string;
|
|
113
119
|
/** Cohesive preset list (labels + raw values + display strings derived from `presets` pairs). */
|
|
114
120
|
presets?: readonly PresetItem[];
|
|
121
|
+
/** Effective open-value flag: the setting's `supportsCustomValues` override, else the
|
|
122
|
+
* type-def's flag. Drives the picker's "Custom value…" row. */
|
|
123
|
+
supportsCustomValues: boolean;
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
// ── Storage levels (which persistence targets a settings handle offers) ─────────
|
package/src/type-definitions.ts
CHANGED
|
@@ -59,6 +59,14 @@ export interface TypeDefinition<T = unknown> {
|
|
|
59
59
|
* from the registry). Absent means this type offers no default presets.
|
|
60
60
|
*/
|
|
61
61
|
presets?: PresetsSource;
|
|
62
|
+
/**
|
|
63
|
+
* Inherently nullable: `null` is always a valid value for this type, even when the
|
|
64
|
+
* effective presets yield no null pair at gate time (a resolver-function presets
|
|
65
|
+
* source — e.g. the model list — resolves its `["Default", null]` pair only at
|
|
66
|
+
* modal-open, so the write gate would otherwise reject null and a model setting
|
|
67
|
+
* could never be unset back to Default).
|
|
68
|
+
*/
|
|
69
|
+
nullable?: boolean;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
// ── Registry ────────────────────────────────────────────────────────────────
|
|
@@ -250,7 +258,7 @@ const booleanType: TypeDefinition<boolean> = {
|
|
|
250
258
|
// ── The thinking-level built-in ─────────────────────────────────────────────
|
|
251
259
|
|
|
252
260
|
/**
|
|
253
|
-
* The
|
|
261
|
+
* The seven thinking levels, as `[label, value]` preset pairs (label equals value). The default
|
|
254
262
|
* `presets` for the `thinking-level` type — a consumer writes only `{ type: "thinking-level" }`
|
|
255
263
|
* and gets these without declaring `presets` (overridable per-setting: replace, not merge).
|
|
256
264
|
*/
|
|
@@ -261,15 +269,18 @@ export const THINKING_LEVEL_PRESETS = [
|
|
|
261
269
|
["medium", "medium"],
|
|
262
270
|
["high", "high"],
|
|
263
271
|
["xhigh", "xhigh"],
|
|
272
|
+
["max", "max"],
|
|
264
273
|
] as const satisfies readonly PresetPair[];
|
|
265
274
|
|
|
266
275
|
const thinkingLevelType: TypeDefinition<string> = {
|
|
267
276
|
id: "thinking-level",
|
|
268
277
|
valueType: "string",
|
|
269
|
-
//
|
|
270
|
-
// override)
|
|
271
|
-
// the setting's defaultValue.
|
|
272
|
-
|
|
278
|
+
// Closed enum when effective presets exist (the type-def default, or a per-setting
|
|
279
|
+
// override): an unknown level returns undefined → the load gate rejects it and falls
|
|
280
|
+
// back to the setting's defaultValue. Empty ctx degrades to identity — the preset
|
|
281
|
+
// normalizer parses bare preset strings with an empty preset ctx, and a consumer may
|
|
282
|
+
// declare the level vocabulary as plain strings (the string type's same contract).
|
|
283
|
+
parse: (input, ctx) => (ctx.presets.length > 0 ? matchPresetValue(input, ctx) : input),
|
|
273
284
|
format: (value) => value,
|
|
274
285
|
presets: THINKING_LEVEL_PRESETS,
|
|
275
286
|
supportsCustomValues: false,
|
|
@@ -296,11 +307,13 @@ const modelType: TypeDefinition<string> = {
|
|
|
296
307
|
id: "model",
|
|
297
308
|
valueType: "string",
|
|
298
309
|
// Identity parse — a provider/id (even a stale one) loads unchanged. Null is valid via the
|
|
299
|
-
// gate's null
|
|
310
|
+
// gate's null path (the `nullable` trait — the resolver's ['Default', null] pair only
|
|
311
|
+
// resolves at modal-open, too late for the write gate), not via parse.
|
|
300
312
|
parse: (input) => input,
|
|
301
313
|
format: (value) => formatModelValue(value),
|
|
302
314
|
presets: resolveModelPresets,
|
|
303
315
|
supportsCustomValues: false,
|
|
316
|
+
nullable: true,
|
|
304
317
|
errorMessage: "Select a model",
|
|
305
318
|
};
|
|
306
319
|
|
package/src/ui/schema-tabs.ts
CHANGED
|
@@ -127,6 +127,8 @@ export function buildSchemaTabGroups(
|
|
|
127
127
|
type: settingSchema.type,
|
|
128
128
|
description: settingSchema.description,
|
|
129
129
|
presets: presetItems,
|
|
130
|
+
supportsCustomValues:
|
|
131
|
+
settingSchema.supportsCustomValues ?? getTypeDefinition(settingSchema.type).supportsCustomValues,
|
|
130
132
|
});
|
|
131
133
|
}
|
|
132
134
|
|
package/src/ui/settings-modal.ts
CHANGED
|
@@ -195,11 +195,13 @@ export class SettingsTabsModal implements Component {
|
|
|
195
195
|
const rawValue = item.value ?? "";
|
|
196
196
|
const itemPresets = item.presets;
|
|
197
197
|
const itemType = item.type;
|
|
198
|
+
const itemSupportsCustom = item.supportsCustomValues;
|
|
198
199
|
tuiItem.submenu = (_currentValue, done) => {
|
|
199
200
|
return new ValuePickerSubmenu(
|
|
200
201
|
itemPresets,
|
|
201
202
|
rawValue,
|
|
202
203
|
itemType,
|
|
204
|
+
itemSupportsCustom,
|
|
203
205
|
this.theme,
|
|
204
206
|
(selectedValue) => {
|
|
205
207
|
if (selectedValue !== undefined) {
|
package/src/ui/value-picker.ts
CHANGED
|
@@ -51,6 +51,11 @@ export class ValuePickerSubmenu implements Component {
|
|
|
51
51
|
* via the global getter — NOT a constructor param. The pi `Theme` constructor param is used
|
|
52
52
|
* only for `fg("muted", ...)` model-span muting. */
|
|
53
53
|
private readonly listTheme = getSettingsListTheme();
|
|
54
|
+
/** Effective open-value flag for THIS setting (per-setting override > type-def flag). */
|
|
55
|
+
private readonly supportsCustom: boolean;
|
|
56
|
+
/** True when the picker opened straight into text-entry (preset-less open setting): Esc
|
|
57
|
+
* closes the editor instead of falling back to a filter list that has nothing to show. */
|
|
58
|
+
private directEntry = false;
|
|
54
59
|
/** True iff this picker is for a `model`-type setting (constructor-time fact — muting applies). */
|
|
55
60
|
private readonly isModel: boolean;
|
|
56
61
|
|
|
@@ -67,6 +72,7 @@ export class ValuePickerSubmenu implements Component {
|
|
|
67
72
|
presets: readonly PresetItem[],
|
|
68
73
|
currentRawValue: string,
|
|
69
74
|
private settingType: SettingType,
|
|
75
|
+
supportsCustomValues: boolean,
|
|
70
76
|
private theme: Theme,
|
|
71
77
|
private done: (value?: unknown) => void,
|
|
72
78
|
maxVisible: number,
|
|
@@ -74,6 +80,7 @@ export class ValuePickerSubmenu implements Component {
|
|
|
74
80
|
this.maxVisible = maxVisible;
|
|
75
81
|
this.filterInput = new Input();
|
|
76
82
|
this.typeDef = getTypeDefinition(settingType);
|
|
83
|
+
this.supportsCustom = supportsCustomValues;
|
|
77
84
|
this.isModel = settingType === "model";
|
|
78
85
|
// Build a TypeContext from presets (a custom parse/format derives the null label itself from
|
|
79
86
|
// ctx.presets — the pair whose value is null).
|
|
@@ -82,7 +89,9 @@ export class ValuePickerSubmenu implements Component {
|
|
|
82
89
|
// Build entries: label for display, raw value for done callback.
|
|
83
90
|
// currentRawValue (serialized string) is matched against preset displayValue to find the active preset.
|
|
84
91
|
const matchIndex = presets.findIndex((p) => p.displayValue === currentRawValue);
|
|
85
|
-
|
|
92
|
+
// An empty current raw value is the NULL display of a preset-less string — there is no
|
|
93
|
+
// current value to indicate, so no indicator row (just presets + the custom entry).
|
|
94
|
+
const hasCustomIndicator = matchIndex < 0 && currentRawValue !== "";
|
|
86
95
|
|
|
87
96
|
this.entries = [];
|
|
88
97
|
if (hasCustomIndicator) {
|
|
@@ -95,10 +104,11 @@ export class ValuePickerSubmenu implements Component {
|
|
|
95
104
|
for (const preset of presets) {
|
|
96
105
|
this.entries.push({ label: preset.label, rawValue: preset.rawValue, kind: "preset" });
|
|
97
106
|
}
|
|
98
|
-
// Pinned "Custom value…" row (open
|
|
99
|
-
// switches the top field into text-entry mode. Distinguished by kind (rawValue is
|
|
100
|
-
// is a valid preset value).
|
|
101
|
-
|
|
107
|
+
// Pinned "Custom value…" row (open settings only): free-form entry via Up/Down + Enter,
|
|
108
|
+
// which switches the top field into text-entry mode. Distinguished by kind (rawValue is
|
|
109
|
+
// null, which is a valid preset value). The flag is the EFFECTIVE per-setting one — a
|
|
110
|
+
// `supportsCustomValues: true` override opens an otherwise-closed type (e.g. string).
|
|
111
|
+
if (this.supportsCustom) {
|
|
102
112
|
this.entries.push({ label: "Custom value…", rawValue: null, kind: "custom" });
|
|
103
113
|
}
|
|
104
114
|
|
|
@@ -113,6 +123,14 @@ export class ValuePickerSubmenu implements Component {
|
|
|
113
123
|
|
|
114
124
|
// The top field is the filter; in text-entry mode it builds a custom value.
|
|
115
125
|
this.filterInput.focused = true;
|
|
126
|
+
|
|
127
|
+
// A preset-less OPEN setting has nothing to pick — open directly in text-entry mode,
|
|
128
|
+
// prefilled with the current value (edit-in-place), instead of a list whose only row
|
|
129
|
+
// is "Custom value…". Esc then closes the editor (there is no list to fall back to).
|
|
130
|
+
this.directEntry = presets.length === 0 && supportsCustomValues;
|
|
131
|
+
if (this.directEntry) {
|
|
132
|
+
this.enterTextEntry(currentRawValue);
|
|
133
|
+
}
|
|
116
134
|
}
|
|
117
135
|
|
|
118
136
|
/** Format a serialized raw value for display via the TypeDefinition. */
|
|
@@ -302,6 +320,12 @@ export class ValuePickerSubmenu implements Component {
|
|
|
302
320
|
return;
|
|
303
321
|
}
|
|
304
322
|
if (kb.matches(data, "tui.select.cancel")) {
|
|
323
|
+
// Direct-entry editors have no list to return to — Esc closes the picker (cancel edit).
|
|
324
|
+
if (this.directEntry) {
|
|
325
|
+
this.closed = true;
|
|
326
|
+
this.done();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
305
329
|
this.leaveTextEntry();
|
|
306
330
|
return;
|
|
307
331
|
}
|
|
@@ -309,21 +333,23 @@ export class ValuePickerSubmenu implements Component {
|
|
|
309
333
|
this.filterInput.handleInput(data);
|
|
310
334
|
}
|
|
311
335
|
|
|
312
|
-
/** Enter on the focused entry: preset/indicator → done(rawValue); Custom row → text-entry
|
|
336
|
+
/** Enter on the focused entry: preset/indicator → done(rawValue); Custom row → text-entry
|
|
337
|
+
* mode (empty editor — the direct-open path is what prefills the current value). */
|
|
313
338
|
private activateEntry(): void {
|
|
314
339
|
const entry = this.visibleEntries[this.selectedIndex];
|
|
315
340
|
if (!entry) return;
|
|
316
341
|
if (entry.kind === "custom") {
|
|
317
|
-
this.enterTextEntry();
|
|
342
|
+
this.enterTextEntry("");
|
|
318
343
|
return;
|
|
319
344
|
}
|
|
320
345
|
this.closed = true;
|
|
321
346
|
this.done(entry.rawValue);
|
|
322
347
|
}
|
|
323
348
|
|
|
324
|
-
private enterTextEntry(): void {
|
|
349
|
+
private enterTextEntry(prefill: string): void {
|
|
325
350
|
this.customEntryFilterSnapshot = this.filterInput.getValue();
|
|
326
|
-
|
|
351
|
+
// Prefill the editor with the given value (edit-in-place; "" starts empty).
|
|
352
|
+
this.filterInput.setValue(prefill);
|
|
327
353
|
this.mode = "text-entry";
|
|
328
354
|
this.errorMessage = "";
|
|
329
355
|
}
|
|
@@ -338,7 +364,11 @@ export class ValuePickerSubmenu implements Component {
|
|
|
338
364
|
|
|
339
365
|
/** Validate the typed custom value; on success fire done(rawValue), on failure show the error. */
|
|
340
366
|
private submitCustomValue(): void {
|
|
341
|
-
|
|
367
|
+
// An override-opened closed type (e.g. string) validates custom text against the OPEN
|
|
368
|
+
// parse — preset matching is a picker affordance there, not a validity gate. Types that
|
|
369
|
+
// are natively open keep their full ctx (their parse handles arbitrary input anyway).
|
|
370
|
+
const customCtx = this.typeDef.supportsCustomValues ? this.ctx : null;
|
|
371
|
+
const result = validateCustomValue(this.filterInput.getValue(), this.settingType, customCtx);
|
|
342
372
|
if (result === undefined) {
|
|
343
373
|
this.errorMessage = ` \u26a0 ${this.typeDef.errorMessage}`;
|
|
344
374
|
return;
|