@mrclrchtr/supi-lsp 2.0.6 → 2.2.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.
Files changed (39) hide show
  1. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/README.md +32 -23
  2. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/package.json +2 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/config/config.ts +22 -8
  4. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/config/prompt-surface.ts +357 -0
  5. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/config.ts +2 -3
  6. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -5
  7. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/prompt-surface.ts +4 -0
  8. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/settings/scoped-settings-list.ts +372 -0
  9. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/settings/settings-action-menu.ts +101 -0
  10. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/settings/settings-command.ts +1 -1
  11. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +55 -27
  12. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +464 -0
  13. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/settings/settings-submenus.ts +124 -0
  14. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/settings/settings-ui.ts +72 -285
  15. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +2 -5
  16. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/settings.ts +27 -5
  17. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/tool-framework.ts +10 -0
  18. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +2 -2
  19. package/node_modules/@mrclrchtr/supi-core/README.md +32 -23
  20. package/node_modules/@mrclrchtr/supi-core/package.json +2 -1
  21. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +22 -8
  22. package/node_modules/@mrclrchtr/supi-core/src/config/prompt-surface.ts +357 -0
  23. package/node_modules/@mrclrchtr/supi-core/src/config.ts +2 -3
  24. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -5
  25. package/node_modules/@mrclrchtr/supi-core/src/prompt-surface.ts +4 -0
  26. package/node_modules/@mrclrchtr/supi-core/src/settings/scoped-settings-list.ts +372 -0
  27. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-action-menu.ts +101 -0
  28. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-command.ts +1 -1
  29. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +55 -27
  30. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +464 -0
  31. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-submenus.ts +124 -0
  32. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-ui.ts +72 -285
  33. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +2 -5
  34. package/node_modules/@mrclrchtr/supi-core/src/settings.ts +27 -5
  35. package/node_modules/@mrclrchtr/supi-core/src/tool-framework.ts +10 -0
  36. package/package.json +3 -3
  37. package/src/config/lsp-settings.ts +1 -1
  38. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/@mrclrchtr/supi-core/src/config/config-settings.ts +0 -188
  39. package/node_modules/@mrclrchtr/supi-core/src/config/config-settings.ts +0 -188
@@ -0,0 +1,464 @@
1
+ // Declarative settings schema for SuPi extensions.
2
+ //
3
+ // Replaces the imperative config-backed buildItems/persistChange contribution
4
+ // with a declarative field descriptor model. The shared settings module owns
5
+ // scope inheritance, source-state resolution, value rendering, persistence,
6
+ // and Inherit/Reset-to-default actions.
7
+ //
8
+ // Custom fields remain for nested or unusual config; they report the same
9
+ // source state as declarative flat fields.
10
+
11
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import type { Component } from "@earendil-works/pi-tui";
13
+ import {
14
+ loadSupiConfigSectionForScope,
15
+ removeSupiConfigKey,
16
+ writeSupiConfig,
17
+ } from "../config/config.ts";
18
+ import {
19
+ isSettingsContributionCollector,
20
+ type SettingsScope,
21
+ type SettingsSection,
22
+ SUPI_SETTINGS_COLLECT_EVENT,
23
+ } from "./settings-registry.ts";
24
+
25
+ // ── Types ──────────────────────────────────────────────────────────────────
26
+
27
+ /** Where the current effective value comes from. */
28
+ export type ValueSource = "project" | "global" | "default";
29
+
30
+ /** Structured notification fired to afterPersist. */
31
+ export interface SettingsPersistedChange {
32
+ scope: SettingsScope;
33
+ cwd: string;
34
+ /** The config key that was mutated. */
35
+ fieldKey: string;
36
+ /** What happened: set an explicit value or deleted the scoped key. */
37
+ action: "set" | "delete";
38
+ /** The value written (only present for "set"). */
39
+ storedValue?: unknown;
40
+ /** The effective value after the save (merging defaults ← global ← project). */
41
+ effectiveValue: unknown;
42
+ /** Where the effective value now comes from. */
43
+ effectiveSource: ValueSource;
44
+ }
45
+
46
+ /** Helpers passed to custom-field persist handlers. */
47
+ export interface ConfigHelpers {
48
+ set(key: string, value: unknown): void;
49
+ unset(key: string): void;
50
+ }
51
+
52
+ // ── Field actions ─────────────────────────────────────────────────────────
53
+
54
+ /** A user-initiated action on a settings row. */
55
+ export type SettingsFieldAction =
56
+ | { kind: "set"; value: string }
57
+ | { kind: "inherit" }
58
+ | { kind: "resetToDefault" };
59
+
60
+ // ── Field descriptors ─────────────────────────────────────────────────────
61
+
62
+ interface BaseField {
63
+ /** Config key in the section (e.g. "enabled", "severity"). */
64
+ key: string;
65
+ /** Display label. */
66
+ label: string;
67
+ /** Optional description shown when the row is selected. */
68
+ description?: string;
69
+ }
70
+
71
+ /** Boolean on/off toggle. */
72
+ export interface BoolField extends BaseField {
73
+ kind: "boolean";
74
+ }
75
+
76
+ /** Enumeration of string choices (cycle via Space). */
77
+ export interface EnumField extends BaseField {
78
+ kind: "enum";
79
+ values: string[];
80
+ }
81
+
82
+ /** Integer field; with discrete values for cycling or absent for free input. */
83
+ export interface NumberField extends BaseField {
84
+ kind: "number";
85
+ /** Discrete choices for Space cycling; absent = free text input. */
86
+ values?: string[];
87
+ }
88
+
89
+ /** Comma-separated string list. */
90
+ export interface StringListField extends BaseField {
91
+ kind: "stringList";
92
+ }
93
+
94
+ /** Model picker backed by the scoped model set. */
95
+ export interface ModelPickerField extends BaseField {
96
+ kind: "modelPicker";
97
+ }
98
+
99
+ /**
100
+ * Custom / escape-hatch field for nested config or unusual controls.
101
+ *
102
+ * The field must report its display value and source so the settings UI
103
+ * can render consistent source badges and action menus.
104
+ */
105
+ export interface CustomField extends BaseField {
106
+ kind: "custom";
107
+ /**
108
+ * Return the display value and its source for the given scope.
109
+ * Called on every scope toggle and after persistence.
110
+ */
111
+ resolve: (
112
+ scope: SettingsScope,
113
+ cwd: string,
114
+ ctx?: ExtensionContext,
115
+ ) => {
116
+ /** Human-readable value text, without the source badge. */
117
+ displayValue: string;
118
+ /** Value used to prefill editors/pickers; defaults to displayValue when omitted. */
119
+ editValue?: string;
120
+ source: ValueSource;
121
+ /** When scope is "project" and source is "project", the source after deletion. */
122
+ inheritanceSource?: "global" | "default";
123
+ };
124
+ /**
125
+ * Submenu component factory for editing (Enter).
126
+ * Receives the resolved display value and a done callback; return a pi-tui
127
+ * Component-like object. Undefined means Enter opens the action menu only.
128
+ */
129
+ submenu?: (
130
+ currentValue: string,
131
+ done: (selectedValue?: string) => void,
132
+ scope: SettingsScope,
133
+ cwd: string,
134
+ ctx?: ExtensionContext,
135
+ ) => Component;
136
+ /**
137
+ * Persist handler called on set/inherit/resetToDefault actions.
138
+ * Required for custom fields so they can write their nested config.
139
+ */
140
+ persist: (
141
+ scope: SettingsScope,
142
+ cwd: string,
143
+ action: SettingsFieldAction,
144
+ helpers: ConfigHelpers,
145
+ ) => void;
146
+ }
147
+
148
+ /** Union of all supported field kinds. */
149
+ export type SettingsField =
150
+ | BoolField
151
+ | EnumField
152
+ | NumberField
153
+ | StringListField
154
+ | ModelPickerField
155
+ | CustomField;
156
+
157
+ // ── Contribution options ──────────────────────────────────────────────────
158
+
159
+ /** Options for registerDeclarativeSettings. */
160
+ export interface DeclarativeSettingsOptions {
161
+ /** Stable contribution identifier — e.g. "lsp", "claude-md". */
162
+ id: string;
163
+ /** Human-readable label shown in the UI. */
164
+ label: string;
165
+ /** SuPi config section name — e.g. "lsp", "claude-md". */
166
+ section: string;
167
+ /** Package-default config values (indexable by field key). */
168
+ defaults: Record<string, unknown>;
169
+ /** Declarative field descriptors. */
170
+ fields: SettingsField[];
171
+ /** Optional live runtime sync after successful persistence. */
172
+ afterPersist?: (change: SettingsPersistedChange) => void;
173
+ /** Optional home directory for config resolution (testing). */
174
+ homeDir?: string;
175
+ }
176
+
177
+ // ── Scoped section interface ─────────────────────────────────────────────
178
+
179
+ /** Resolved value for one field in one scope. */
180
+ export interface ScopedFieldValue {
181
+ /** The field descriptor. */
182
+ field: SettingsField;
183
+ /** Display value string shown in the row (with source badge). */
184
+ displayValue: string;
185
+ /** Value used to prefill editors/pickers, without the source badge. */
186
+ editValue: string;
187
+ /** Where the value comes from. */
188
+ source: ValueSource;
189
+ /**
190
+ * When scope is "project" and source is "project", the source that would
191
+ * apply after deleting the project override ("global" or "default").
192
+ * Undefined otherwise.
193
+ */
194
+ inheritanceSource?: "global" | "default";
195
+ }
196
+
197
+ // ── Source resolution ─────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * Resolve the effective value and source for a flat key.
201
+ *
202
+ * For project scope: checks project → global → defaults.
203
+ * For global scope: checks global → defaults.
204
+ */
205
+ // biome-ignore lint/complexity/useMaxParams: resolveValue needs all scope/source parameters for honest multi-tier resolution
206
+ export function resolveValue<T extends Record<string, unknown>>(
207
+ key: string,
208
+ defaults: T,
209
+ projectRaw: Record<string, unknown> | null,
210
+ globalRaw: Record<string, unknown> | null,
211
+ scope: SettingsScope,
212
+ ): { value: unknown; source: ValueSource } {
213
+ // Check direct scope first
214
+ const directRaw = scope === "project" ? projectRaw : globalRaw;
215
+ if (directRaw && key in directRaw) {
216
+ return { value: directRaw[key], source: scope };
217
+ }
218
+
219
+ // For project scope, check global
220
+ if (scope === "project" && globalRaw && key in globalRaw) {
221
+ return { value: globalRaw[key], source: "global" };
222
+ }
223
+
224
+ // Fall back to defaults
225
+ return { value: defaults[key], source: "default" };
226
+ }
227
+
228
+ // ── Value formatting ──────────────────────────────────────────────────────
229
+
230
+ /** Format a value for display. */
231
+ export function formatValue(value: unknown, field: SettingsField): string {
232
+ switch (field.kind) {
233
+ case "boolean":
234
+ return value ? "on" : "off";
235
+ case "number":
236
+ return String(value ?? "");
237
+ case "stringList": {
238
+ const arr = Array.isArray(value) ? value : [];
239
+ return arr.length > 0 ? arr.map(String).join(", ") : "none";
240
+ }
241
+ default:
242
+ return String(value ?? "");
243
+ }
244
+ }
245
+
246
+ /** Build source-badged display text. */
247
+ export function sourceBadge(displayValue: string, source: ValueSource): string {
248
+ switch (source) {
249
+ case "project":
250
+ return `${displayValue} (project)`;
251
+ case "global":
252
+ return `${displayValue} (global)`;
253
+ case "default":
254
+ return `${displayValue} (default)`;
255
+ }
256
+ }
257
+
258
+ /** Format the value used to prefill editors and compare concrete choices. */
259
+ export function formatEditValue(value: unknown, field: SettingsField): string {
260
+ if (field.kind === "stringList") {
261
+ const arr = Array.isArray(value) ? value : [];
262
+ return arr.map(String).join(", ");
263
+ }
264
+ return formatValue(value, field);
265
+ }
266
+
267
+ // ── Persistence helpers ───────────────────────────────────────────────────
268
+
269
+ function createConfigHelpers(
270
+ section: string,
271
+ scope: SettingsScope,
272
+ cwd: string,
273
+ homeDir?: string,
274
+ ): ConfigHelpers {
275
+ return {
276
+ set: (key: string, val: unknown) => {
277
+ writeSupiConfig({ section, scope, cwd }, { [key]: val }, { homeDir });
278
+ },
279
+ unset: (key: string) => {
280
+ removeSupiConfigKey({ section, scope, cwd }, key, { homeDir });
281
+ },
282
+ };
283
+ }
284
+
285
+ // ── Scoped section factory ────────────────────────────────────────────────
286
+
287
+ interface NotifyAfterPersistInput {
288
+ options: DeclarativeSettingsOptions;
289
+ field: SettingsField;
290
+ scope: SettingsScope;
291
+ cwd: string;
292
+ action: SettingsFieldAction;
293
+ storedValue: unknown;
294
+ ctx?: ExtensionContext;
295
+ }
296
+
297
+ function notifyAfterPersist(input: NotifyAfterPersistInput): void {
298
+ const { options, field, scope, cwd, action, storedValue, ctx } = input;
299
+ if (!options.afterPersist) return;
300
+
301
+ let effectiveValue: unknown;
302
+ let effectiveSource: ValueSource;
303
+
304
+ if (field.kind === "custom") {
305
+ const resolved = field.resolve(scope, cwd, ctx);
306
+ effectiveValue = resolved.editValue ?? resolved.displayValue;
307
+ effectiveSource = resolved.source;
308
+ } else {
309
+ const projectRaw = loadSupiConfigSectionForScope(options.section, cwd, {
310
+ scope: "project",
311
+ homeDir: options.homeDir,
312
+ });
313
+ const globalRaw = loadSupiConfigSectionForScope(options.section, cwd, {
314
+ scope: "global",
315
+ homeDir: options.homeDir,
316
+ });
317
+ const resolved = resolveValue(field.key, options.defaults, projectRaw, globalRaw, scope);
318
+ effectiveValue = resolved.value;
319
+ effectiveSource = resolved.source;
320
+ }
321
+
322
+ const change: SettingsPersistedChange = {
323
+ scope,
324
+ cwd,
325
+ fieldKey: field.key,
326
+ action: action.kind === "set" ? "set" : "delete",
327
+ effectiveValue,
328
+ effectiveSource,
329
+ };
330
+ if (action.kind === "set") change.storedValue = storedValue;
331
+ options.afterPersist(change);
332
+ }
333
+
334
+ function toDeclarativeSection(options: DeclarativeSettingsOptions): SettingsSection {
335
+ const defaults = options.defaults as Record<string, unknown>;
336
+ return {
337
+ id: options.id,
338
+ label: options.label,
339
+ loadValues: (scope, cwd, ctx) => {
340
+ // Load raw section data for both scopes (no defaults)
341
+ const projectRaw = loadSupiConfigSectionForScope(options.section, cwd, {
342
+ scope: "project",
343
+ homeDir: options.homeDir,
344
+ });
345
+ const globalRaw = loadSupiConfigSectionForScope(options.section, cwd, {
346
+ scope: "global",
347
+ homeDir: options.homeDir,
348
+ });
349
+
350
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: natural discriminator on field kind and source
351
+ return options.fields.map((field) => {
352
+ if (field.kind === "custom") {
353
+ const resolved = field.resolve(scope, cwd, ctx);
354
+ return {
355
+ field,
356
+ displayValue: resolved.displayValue
357
+ ? sourceBadge(resolved.displayValue, resolved.source)
358
+ : "",
359
+ editValue: resolved.editValue ?? resolved.displayValue,
360
+ source: resolved.source,
361
+ inheritanceSource: resolved.inheritanceSource,
362
+ };
363
+ }
364
+
365
+ const { value, source } = resolveValue(field.key, defaults, projectRaw, globalRaw, scope);
366
+ const displayValue = formatValue(value, field);
367
+
368
+ // Compute inheritanceSource for project-scope overrides
369
+ let inheritanceSource: "global" | "default" | undefined;
370
+ if (scope === "project" && source === "project") {
371
+ inheritanceSource = globalRaw && field.key in globalRaw ? "global" : "default";
372
+ }
373
+
374
+ return {
375
+ field,
376
+ displayValue: sourceBadge(displayValue, source),
377
+ editValue: formatEditValue(value, field),
378
+ source,
379
+ inheritanceSource,
380
+ };
381
+ });
382
+ },
383
+ // biome-ignore lint/complexity/useMaxParams: SettingsSection action handlers receive scope, cwd, field, action, and optional context
384
+ handleAction: (scope, cwd, fieldKey, action, ctx) => {
385
+ const field = options.fields.find((f) => f.key === fieldKey);
386
+ if (!field) return;
387
+
388
+ const section = options.section;
389
+ const helpers = createConfigHelpers(section, scope, cwd, options.homeDir);
390
+ let storedValue: unknown;
391
+
392
+ if (field.kind === "custom") {
393
+ field.persist(scope, cwd, action, helpers);
394
+ storedValue = action.kind === "set" ? action.value : undefined;
395
+ notifyAfterPersist({ options, field, scope, cwd, action, storedValue, ctx });
396
+ return;
397
+ }
398
+
399
+ switch (action.kind) {
400
+ case "set": {
401
+ storedValue = parseTypedValue(action.value, field);
402
+ helpers.set(field.key, storedValue);
403
+ break;
404
+ }
405
+ case "inherit":
406
+ case "resetToDefault": {
407
+ helpers.unset(field.key);
408
+ break;
409
+ }
410
+ }
411
+
412
+ notifyAfterPersist({ options, field, scope, cwd, action, storedValue, ctx });
413
+ },
414
+ };
415
+ }
416
+
417
+ // ── Typed value parsing ───────────────────────────────────────────────────
418
+
419
+ /** Parse a user-supplied string value into the typed config value for the field. */
420
+ export function parseTypedValue(value: string, field: SettingsField): unknown {
421
+ switch (field.kind) {
422
+ case "boolean":
423
+ return value === "on";
424
+ case "number": {
425
+ if (!/^[1-9]\d*$/.test(value.trim())) {
426
+ throw new Error(
427
+ `Invalid value for "${field.label}": "${value}". Enter a positive integer.`,
428
+ );
429
+ }
430
+ return Number.parseInt(value, 10);
431
+ }
432
+ case "stringList":
433
+ return value
434
+ .split(",")
435
+ .map((s) => s.trim())
436
+ .filter((s) => s.length > 0);
437
+ default:
438
+ return value;
439
+ }
440
+ }
441
+
442
+ // ── Registration ──────────────────────────────────────────────────────────
443
+
444
+ /**
445
+ * Register a declarative settings contribution for `/supi-settings`.
446
+ *
447
+ * Contributions are collected through PI's process-local event bus. Call this
448
+ * during the extension factory function, not in async session handlers.
449
+ */
450
+ export function registerDeclarativeSettings(
451
+ pi: ExtensionAPI,
452
+ options: DeclarativeSettingsOptions,
453
+ ): void {
454
+ const section = toDeclarativeSection(options);
455
+ const dispose = pi.events.on(SUPI_SETTINGS_COLLECT_EVENT, (collector) => {
456
+ if (isSettingsContributionCollector(collector)) {
457
+ collector.add(section);
458
+ }
459
+ });
460
+
461
+ pi.on("session_shutdown", () => {
462
+ dispose();
463
+ });
464
+ }
@@ -0,0 +1,124 @@
1
+ // Submenu helpers for SuPi settings.
2
+ //
3
+ // Reusable pi-tui submenu components shared across the settings overlay
4
+ // and available to extensions for custom settings controls.
5
+
6
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ Container,
9
+ Input,
10
+ Key,
11
+ matchesKey,
12
+ type SelectItem,
13
+ SelectList,
14
+ Text,
15
+ } from "@earendil-works/pi-tui";
16
+ import { getSelectableModels } from "../model-selection.ts";
17
+
18
+ /**
19
+ * Creates a pi-tui Input-backed submenu component with enter-to-confirm
20
+ * and escape-to-cancel handling.
21
+ */
22
+ export function createInputSubmenu(
23
+ currentValue: string,
24
+ label: string,
25
+ done: (selectedValue?: string) => void,
26
+ ): {
27
+ render: (width: number) => string[];
28
+ invalidate: () => void;
29
+ handleInput: (data: string) => boolean;
30
+ } {
31
+ const input = new Input();
32
+ input.setValue(currentValue);
33
+
34
+ return {
35
+ render: (_width: number) => {
36
+ const lines = [` ${label}`];
37
+ lines.push(...input.render(_width));
38
+ lines.push(" enter confirm • esc cancel");
39
+ return lines;
40
+ },
41
+ invalidate: () => {
42
+ input.invalidate();
43
+ },
44
+ handleInput: (data: string) => {
45
+ if (matchesKey(data, Key.escape)) {
46
+ done();
47
+ return true;
48
+ }
49
+ if (matchesKey(data, Key.enter)) {
50
+ done(input.getValue());
51
+ return true;
52
+ }
53
+ input.handleInput(data);
54
+ return true;
55
+ },
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Creates a model picker submenu for settings with "disabled" as the first option.
61
+ */
62
+ export function createModelPickerSubmenu(
63
+ currentValue: string,
64
+ done: (selectedValue?: string) => void,
65
+ ctx?: ExtensionContext,
66
+ ): {
67
+ render: (width: number) => string[];
68
+ invalidate: () => void;
69
+ handleInput: (data: string) => boolean;
70
+ } {
71
+ const items = buildModelItems(ctx);
72
+ const initialIndex =
73
+ currentValue === "disabled"
74
+ ? 0
75
+ : Math.max(
76
+ 0,
77
+ items.findIndex((item) => item.value === currentValue),
78
+ );
79
+
80
+ const container = new Container();
81
+ container.addChild(new Text(" Select model", 1, 0));
82
+ container.addChild(new Text("", 1, 0));
83
+
84
+ const selectList = new SelectList(items, Math.min(items.length, 15), {
85
+ selectedPrefix: (t) => `› ${t}`,
86
+ selectedText: (t) => t,
87
+ description: (t) => t,
88
+ scrollInfo: (t) => t,
89
+ noMatch: (t) => t,
90
+ });
91
+ if (initialIndex >= 0) selectList.setSelectedIndex(initialIndex);
92
+ selectList.onSelect = (item) => done(item.value);
93
+ selectList.onCancel = () => done();
94
+
95
+ container.addChild(selectList);
96
+ container.addChild(new Text(" ↑↓ navigate • enter select • esc cancel", 1, 0));
97
+
98
+ return {
99
+ render: (width: number) => container.render(width),
100
+ invalidate: () => container.invalidate(),
101
+ handleInput: (data: string) => {
102
+ selectList.handleInput(data);
103
+ return true;
104
+ },
105
+ };
106
+ }
107
+
108
+ /** Build selectable model items with "disabled" as the first option. */
109
+ function buildModelItems(ctx?: ExtensionContext): SelectItem[] {
110
+ const items: SelectItem[] = [
111
+ { value: "disabled", label: "disabled", description: "No model selected" },
112
+ ];
113
+ if (!ctx) return items;
114
+ const models = getSelectableModels(ctx);
115
+ for (const model of models) {
116
+ const suffix = model.isCurrent ? " [current]" : "";
117
+ items.push({
118
+ value: model.canonicalId,
119
+ label: `${model.canonicalId}${suffix}`,
120
+ description: model.label !== model.canonicalId ? model.label : undefined,
121
+ });
122
+ }
123
+ return items;
124
+ }