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 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.8",
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.0.0"
27
+ "minimatch": "^10.2.6"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@biomejs/biome": "^2.5.0",
@@ -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). A type with no null-preset rejects null. Legitimate nulls never reach parse.
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 p = typeDef.parse(value, buildTypeContext(setting));
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
- // Display fallback only — the null label for nullable settings, or "Infinite" defensively
279
- // for non-nullable settings that shouldn't hold null anyway.
280
- return resolveNullLabel(setting) ?? "Infinite";
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) ─────────
@@ -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 six thinking levels, as `[label, value]` preset pairs (label equals value). The default
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
- // Membership check against the EFFECTIVE presets (the type-def default, or a per-setting
270
- // override). An unknown level returns undefined → the load gate rejects it and falls back to
271
- // the setting's defaultValue.
272
- parse: (input, ctx) => matchPresetValue(input, ctx),
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-preset path (the resolver's ['Default', null] pair), not via parse.
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
 
@@ -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
 
@@ -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) {
@@ -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
- const hasCustomIndicator = matchIndex < 0;
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 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) {
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 mode. */
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
- this.filterInput.setValue("");
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
- const result = validateCustomValue(this.filterInput.getValue(), this.settingType, this.ctx);
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;