pi-better-model-select 0.1.3 → 0.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.
@@ -1,8 +1,9 @@
1
1
  // Add / update / delete flows against the effective model configuration.
2
2
  //
3
- // Each mutation asks which file to write, then re-reads that file immediately
3
+ // Each mutation follows the saved write mode, then re-reads the target file
4
4
  // before saving so another pi session's edit is not silently clobbered.
5
5
 
6
+ import { editModelEfforts, saveModelEfforts, type EffortDraft } from "./effort-editor.ts";
6
7
  import {
7
8
  DEFAULT_CONTEXT_WINDOW,
8
9
  DEFAULT_MAX_TOKENS,
@@ -17,9 +18,11 @@ import {
17
18
  configContainsModel,
18
19
  loadConfig,
19
20
  loadConfigFile,
21
+ loadSettings,
20
22
  saveConfig,
21
23
  tombstoneModel,
22
24
  upsertModel,
25
+ writeTarget,
23
26
  type ConfigTarget,
24
27
  type ModelEntry,
25
28
  type ModelsConfig,
@@ -27,6 +30,8 @@ import {
27
30
 
28
31
  // Minimal shape of the pi extension command context we rely on.
29
32
  export interface FlowCtx {
33
+ cwd?: string;
34
+ isProjectTrusted?: () => boolean;
30
35
  ui: {
31
36
  notify: (message: string, level: string) => void;
32
37
  select: (message: string, options: string[]) => Promise<string | undefined>;
@@ -37,20 +42,8 @@ export interface FlowCtx {
37
42
 
38
43
  const INPUT_OPTIONS: string[][] = [["text"], ["text", "image"]];
39
44
 
40
- const TARGET_LABELS = {
41
- "models.local.json": "models.local.json (personal BMS overlay)",
42
- "models.json": "models.json (Pi configuration)",
43
- } satisfies Record<ConfigTarget, string>;
44
-
45
- async function selectWriteTarget(
46
- ctx: FlowCtx,
47
- message = "Write changes to which file?",
48
- ): Promise<ConfigTarget | undefined> {
49
- const options = [TARGET_LABELS["models.local.json"], TARGET_LABELS["models.json"]];
50
- const pick = await ctx.ui.select(message, options);
51
- if (pick === TARGET_LABELS["models.local.json"]) return "models.local.json";
52
- if (pick === TARGET_LABELS["models.json"]) return "models.json";
53
- return undefined;
45
+ function selectedWriteTarget(): ConfigTarget {
46
+ return writeTarget(loadSettings());
54
47
  }
55
48
 
56
49
  function providerOverlay(config: ModelsConfig, providerName: string): Record<string, unknown> {
@@ -102,7 +95,7 @@ async function selectInput(
102
95
 
103
96
  // ----- add -----------------------------------------------------------------
104
97
 
105
- export async function addFlow(ctx: FlowCtx): Promise<void> {
98
+ export async function addFlow(ctx: FlowCtx, onAdded?: (provider: string, modelId: string) => Promise<void>): Promise<void> {
106
99
  const config = loadConfig(ctx);
107
100
  if (!config) return;
108
101
  const providers = config.providers ?? {};
@@ -187,11 +180,13 @@ export async function addFlow(ctx: FlowCtx): Promise<void> {
187
180
  if (input === undefined) return;
188
181
  crumbs.push(`Input: ${input.join("+")}`);
189
182
 
190
- const writeTarget = await selectWriteTarget(ctx);
191
- if (!writeTarget) return;
183
+ const effortDraft = await editModelEfforts(ctx, `${providerName}/${modelId}`, reasoning, meta.effortLevels);
184
+ if (!effortDraft) return;
185
+
186
+ const targetFile = selectedWriteTarget();
192
187
  const ok = await ctx.ui.confirm(
193
188
  `${header()}Add this model to "${providerName}"?`,
194
- `Write the entry to ${writeTarget}?`,
189
+ `Write the entry to ${targetFile}?`,
195
190
  );
196
191
  if (!ok) {
197
192
  ctx.ui.notify("Cancelled. Nothing written.", "info");
@@ -199,7 +194,7 @@ export async function addFlow(ctx: FlowCtx): Promise<void> {
199
194
  }
200
195
 
201
196
  const freshEffective = loadConfig(ctx);
202
- const freshTarget = loadConfigFile(ctx, writeTarget, writeTarget === "models.local.json");
197
+ const freshTarget = loadConfigFile(ctx, targetFile, targetFile === "models.bms.json");
203
198
  if (!freshEffective || !freshTarget) return;
204
199
  if (!freshEffective.providers?.[providerName]) {
205
200
  ctx.ui.notify(`Provider "${providerName}" no longer exists.`, "error");
@@ -209,6 +204,7 @@ export async function addFlow(ctx: FlowCtx): Promise<void> {
209
204
  ctx.ui.notify(`Model "${modelId}" is already configured in ${providerName}.`, "warning");
210
205
  return;
211
206
  }
207
+ if (!saveModelEfforts(ctx, `${providerName}/${modelId}`, effortDraft)) return;
212
208
  ensureProviderSettings(freshTarget, freshEffective, providerName);
213
209
  upsertModel(freshTarget, providerName, {
214
210
  id: modelId,
@@ -218,8 +214,9 @@ export async function addFlow(ctx: FlowCtx): Promise<void> {
218
214
  contextWindow,
219
215
  maxTokens,
220
216
  });
221
- saveConfig(freshTarget, writeTarget);
222
- ctx.ui.notify(`Added "${modelId}" to ${providerName} in ${writeTarget}.`, "info");
217
+ saveConfig(freshTarget, targetFile);
218
+ ctx.ui.notify(`Added "${modelId}" to ${providerName} in ${targetFile}.`, "info");
219
+ if (onAdded && await ctx.ui.confirm("Set as Pi default?", "Configure this model and effort as the default for new sessions?")) await onAdded(providerName, modelId);
223
220
  }
224
221
 
225
222
  // ----- update --------------------------------------------------------------
@@ -287,7 +284,20 @@ export async function updateFlow(
287
284
  );
288
285
  }
289
286
 
287
+ let effortDraft: EffortDraft | undefined;
290
288
  const fields: Field[] = [
289
+ {
290
+ key: "bmsEfforts",
291
+ label: "Available / preferred efforts",
292
+ current: "edit capability levels and Pi per-model preference",
293
+ suggest: meta.effortLevels?.join(", "),
294
+ edit: async () => {
295
+ const draft = await editModelEfforts(ctx, `${providerName}/${existing.id}`, Boolean(changes.get("reasoning") ?? existing.reasoning), meta.effortLevels);
296
+ if (!draft) return;
297
+ effortDraft = draft;
298
+ return `${draft.capability.levels.join(", ")}; preferred ${draft.preferred ?? "automatic"}`;
299
+ },
300
+ },
291
301
  {
292
302
  key: "name",
293
303
  label: "Display name",
@@ -374,6 +384,10 @@ export async function updateFlow(
374
384
  const value = await field.edit(() => `Model: ${existing.id} [${providerName}]\n`);
375
385
  if (value === undefined) continue; // ESC in the field editor — back to list
376
386
  changes.set(field.key, value);
387
+ if (field.key === "reasoning") {
388
+ effortDraft = undefined;
389
+ changes.delete("bmsEfforts");
390
+ }
377
391
  }
378
392
 
379
393
  if (changes.size === 0) {
@@ -381,11 +395,10 @@ export async function updateFlow(
381
395
  return;
382
396
  }
383
397
 
384
- const writeTarget = await selectWriteTarget(ctx);
385
- if (!writeTarget) return;
398
+ const targetFile = selectedWriteTarget();
386
399
  const ok = await ctx.ui.confirm(
387
400
  `Update ${existing.id}?`,
388
- `Write ${[...changes.keys()].join(", ")} to "${providerName}" in ${writeTarget}?`,
401
+ `Write ${[...changes.keys()].join(", ")} to "${providerName}" in ${targetFile}?`,
389
402
  );
390
403
  if (!ok) {
391
404
  ctx.ui.notify("Cancelled. Nothing updated.", "info");
@@ -393,19 +406,24 @@ export async function updateFlow(
393
406
  }
394
407
 
395
408
  const freshEffective = loadConfig(ctx);
396
- const freshTarget = loadConfigFile(ctx, writeTarget, writeTarget === "models.local.json");
409
+ const freshTarget = loadConfigFile(ctx, targetFile, targetFile === "models.bms.json");
397
410
  if (!freshEffective || !freshTarget) return;
398
411
  const modelEntry = completeModel(freshEffective, providerName, existing.id);
399
412
  if (!modelEntry) {
400
413
  ctx.ui.notify(`Model "${existing.id}" no longer exists in ${providerName}.`, "error");
401
414
  return;
402
415
  }
403
- Object.assign(modelEntry, Object.fromEntries(changes));
416
+ if (changes.has("reasoning") && !effortDraft) {
417
+ effortDraft = await editModelEfforts(ctx, `${providerName}/${existing.id}`, Boolean(changes.get("reasoning")), meta.effortLevels);
418
+ if (!effortDraft) return;
419
+ }
420
+ if (effortDraft && !saveModelEfforts(ctx, `${providerName}/${existing.id}`, effortDraft)) return;
421
+ Object.assign(modelEntry, Object.fromEntries([...changes].filter(([key]) => key !== "bmsEfforts")));
404
422
  ensureProviderSettings(freshTarget, freshEffective, providerName);
405
423
  upsertModel(freshTarget, providerName, modelEntry);
406
- saveConfig(freshTarget, writeTarget);
424
+ saveConfig(freshTarget, targetFile);
407
425
  ctx.ui.notify(
408
- `Updated "${existing.id}" in ${providerName} via ${writeTarget}: ${[...changes.entries()]
426
+ `Updated "${existing.id}" in ${providerName} via ${targetFile}: ${[...changes.entries()]
409
427
  .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join("+") : String(v)}`)
410
428
  .join(", ")}.`,
411
429
  "info",
@@ -444,60 +462,66 @@ export async function deleteFlow(
444
462
  ({ provider, id } = map.get(label)!);
445
463
  }
446
464
 
465
+ const settings = loadSettings();
466
+ const targetFile = writeTarget(settings);
447
467
  const base = loadConfigFile(ctx, "models.json");
448
- const local = loadConfigFile(ctx, "models.local.json", true);
449
- if (!base || !local) return;
450
-
451
- const candidates: ConfigTarget[] = [];
452
- if (configContainsModel(local, provider, id)) candidates.push("models.local.json");
453
- if (configContainsModel(base, provider, id)) candidates.push("models.json");
454
- if (candidates.length === 0) {
455
- ctx.ui.notify(`Model "${id}" is no longer present in either config file.`, "warning");
456
- return;
457
- }
468
+ if (!base) return;
469
+
470
+ if (settings.writeMode === "overlay") {
471
+ const overlay = loadConfigFile(ctx, "models.bms.json", true);
472
+ if (!overlay) return;
473
+ const inherited = configContainsModel(base, provider, id);
474
+ const local = configContainsModel(overlay, provider, id);
475
+ if (!inherited && !local) {
476
+ ctx.ui.notify(`Model "${id}" is no longer present in either config file.`, "warning");
477
+ return;
478
+ }
458
479
 
459
- if (candidates.length === 1 && candidates[0] === "models.json") {
460
480
  const ok = await ctx.ui.confirm(
461
- `Hide inherited model ${id}?`,
462
- "Keep models.json unchanged and add a BMS tombstone to models.local.json?",
481
+ `${inherited && !local ? "Hide inherited model" : "Delete"} ${id}?`,
482
+ inherited && !local
483
+ ? "Keep models.json unchanged and add a BMS tombstone to models.bms.json?"
484
+ : `Remove it from "${provider}" in models.bms.json?`,
463
485
  );
464
486
  if (!ok) {
465
487
  ctx.ui.notify("Cancelled. Nothing deleted.", "info");
466
488
  return;
467
489
  }
468
490
 
469
- const freshLocal = loadConfigFile(ctx, "models.local.json", true);
470
- if (!freshLocal) return;
471
- tombstoneModel(freshLocal, provider, id);
472
- saveConfig(freshLocal, "models.local.json");
473
- ctx.ui.notify(`Hidden inherited model "${id}" via models.local.json.`, "info");
491
+ if (inherited) tombstoneModel(overlay, provider, id);
492
+ else {
493
+ const target = overlay.providers?.[provider];
494
+ if (target?.models) target.models = target.models.filter((model) => model.id !== id);
495
+ }
496
+ saveConfig(overlay, "models.bms.json");
497
+ ctx.ui.notify(
498
+ inherited
499
+ ? `Hidden inherited model "${id}" via models.bms.json.`
500
+ : `Deleted "${id}" from ${provider} in models.bms.json.`,
501
+ "info",
502
+ );
474
503
  return;
475
504
  }
476
505
 
477
- let writeTarget: ConfigTarget;
478
- if (candidates.length === 1) {
479
- writeTarget = candidates[0]!;
480
- } else {
481
- const selected = await selectWriteTarget(ctx, "Remove the model from which file?");
482
- if (!selected) return;
483
- writeTarget = selected;
506
+ if (!configContainsModel(base, provider, id)) {
507
+ ctx.ui.notify(`Model "${id}" is no longer present in models.json.`, "warning");
508
+ return;
484
509
  }
485
-
486
510
  const ok = await ctx.ui.confirm(
487
511
  `Delete ${id}?`,
488
- `Remove it from "${provider}" in ${writeTarget}?`,
512
+ `Remove it from "${provider}" in ${targetFile}?`,
489
513
  );
490
514
  if (!ok) {
491
515
  ctx.ui.notify("Cancelled. Nothing deleted.", "info");
492
516
  return;
493
517
  }
494
518
 
495
- const fresh = loadConfigFile(ctx, writeTarget, writeTarget === "models.local.json");
519
+ const fresh = loadConfigFile(ctx, targetFile, targetFile === "models.bms.json");
496
520
  if (!fresh) return;
497
521
  const target = fresh.providers?.[provider];
498
522
  if (target?.models) {
499
523
  target.models = target.models.filter((m) => m.id !== id);
500
- saveConfig(fresh, writeTarget);
501
- ctx.ui.notify(`Deleted "${id}" from ${provider} in ${writeTarget}.`, "info");
524
+ saveConfig(fresh, targetFile);
525
+ ctx.ui.notify(`Deleted "${id}" from ${provider} in ${targetFile}.`, "info");
502
526
  }
503
527
  }
@@ -5,11 +5,20 @@
5
5
  // endpoint yields nothing and the caller falls back to explicit defaults.
6
6
 
7
7
  import type { ModelsConfig } from "./store.ts";
8
+ import { normalizeEfforts, type Effort } from "./defaults-store.ts";
9
+
10
+ /** Only explicit enumerations count. supported_parameters alone is not an effort list. */
11
+ export function metadataEfforts(metadata: Record<string, any>): Effort[] | undefined {
12
+ return normalizeEfforts(metadata.supported_reasoning_efforts)
13
+ ?? normalizeEfforts(metadata.supported_thinking_levels)
14
+ ?? normalizeEfforts(metadata.reasoning_efforts);
15
+ }
8
16
 
9
17
  export const DEFAULT_CONTEXT_WINDOW = 128000;
10
18
  export const DEFAULT_MAX_TOKENS = 32768;
11
19
 
12
20
  export interface ModelMeta {
21
+ effortLevels?: Effort[];
13
22
  contextWindow?: number;
14
23
  maxTokens?: number;
15
24
  reasoning?: boolean;
@@ -55,6 +64,7 @@ export async function fetchModelMeta(
55
64
  key: string,
56
65
  modelId: string,
57
66
  ): Promise<ModelMeta> {
67
+ let effortLevels: Effort[] | undefined;
58
68
  let metaContext: number | undefined;
59
69
  let metaMaxTokens: number | undefined;
60
70
  let metaReasoning: boolean | undefined;
@@ -87,6 +97,8 @@ export async function fetchModelMeta(
87
97
  );
88
98
  if (match?.model_info) {
89
99
  const mi = match.model_info as Record<string, any>;
100
+ effortLevels = metadataEfforts(mi);
101
+ if (typeof mi.supports_reasoning === "boolean") metaReasoning = mi.supports_reasoning;
90
102
  metaContext = mi.max_input_tokens ?? mi.max_tokens;
91
103
  // Only trust an explicit output cap — LiteLLM's generic `max_tokens`
92
104
  // is often a default (e.g. 4096/128000), not the model's real cap.
@@ -106,8 +118,8 @@ export async function fetchModelMeta(
106
118
  // Not a LiteLLM proxy or endpoint unavailable — ignore.
107
119
  }
108
120
 
109
- // 2) OpenAI-style per-model GET
110
- if (metaContext === undefined) {
121
+ // 2) OpenAI-style per-model GET, also used when token metadata lacks effort information.
122
+ if (metaContext === undefined || effortLevels === undefined) {
111
123
  try {
112
124
  const modelRes = await fetch(`${base}/models/${encodeURIComponent(modelId)}`, {
113
125
  headers: authHeaders,
@@ -117,9 +129,10 @@ export async function fetchModelMeta(
117
129
  const b = (await modelRes.json()) as Record<string, any>;
118
130
  // OpenAI /v1/models/{id} does not return token limits, but some proxies
119
131
  // add custom fields like max_context_length or max_model_tokens.
120
- metaContext =
121
- b.max_context_length ?? b.max_input_tokens ?? b.context_window ?? b.max_tokens;
122
- metaMaxTokens = b.max_output_tokens ?? b.max_completion_tokens ?? metaContext;
132
+ effortLevels ??= metadataEfforts(b);
133
+ if (typeof b.reasoning === "boolean") metaReasoning ??= b.reasoning;
134
+ metaContext ??= b.max_context_length ?? b.max_input_tokens ?? b.context_window ?? b.max_tokens;
135
+ metaMaxTokens ??= b.max_output_tokens ?? b.max_completion_tokens;
123
136
  }
124
137
  } catch {
125
138
  // Endpoint not supported — ignore.
@@ -140,7 +153,7 @@ export async function fetchModelMeta(
140
153
  if (!entry) continue;
141
154
  metaContext = entry.max_input_tokens ?? entry.max_tokens;
142
155
  metaMaxTokens = entry.max_output_tokens ?? entry.max_tokens;
143
- metaReasoning =
156
+ metaReasoning ??=
144
157
  entry.mode === "reasoning" || entry.reasoning === true || heuristicReasoning;
145
158
  if (entry.input_cost_per_token > 0 || entry.output_cost_per_token > 0) {
146
159
  metaInput = entry.supports_vision ? ["text", "image"] : ["text"];
@@ -165,7 +178,8 @@ export async function fetchModelMeta(
165
178
  return {
166
179
  contextWindow: metaContext,
167
180
  maxTokens: metaMaxTokens,
168
- reasoning: metaReasoning,
181
+ reasoning: effortLevels?.some(level => level !== "off") ? true : metaReasoning,
182
+ effortLevels,
169
183
  input: metaInput,
170
184
  };
171
185
  }
@@ -0,0 +1,21 @@
1
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Text } from "@earendil-works/pi-tui";
3
+ import { ModelPicker, type PickerAction, type RankedModel } from "./picker.ts";
4
+
5
+ export async function pickerDialog(ctx: any, rows: RankedModel[], title = "Better Model Select", assignment = false): Promise<PickerAction | undefined> {
6
+ return ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) => {
7
+ const container = new Container();
8
+ const accent = (text: string) => typeof theme?.fg === "function" ? theme.fg("accent", text) : text;
9
+ container.addChild(new DynamicBorder(accent));
10
+ container.addChild(new Text(accent(title), 1, 0));
11
+ const picker = new ModelPicker(rows, theme, () => tui.requestRender(), done, assignment);
12
+ picker.focused = true;
13
+ container.addChild(picker);
14
+ container.addChild(new DynamicBorder(accent));
15
+ return {
16
+ render: (width: number) => container.render(width),
17
+ invalidate: () => container.invalidate(),
18
+ handleInput: (data: string) => picker.handleInput(data),
19
+ };
20
+ });
21
+ }
@@ -19,9 +19,12 @@ import { loadUsage, usageKey, type ModelsConfig } from "./store.ts";
19
19
 
20
20
  export type PickerAction =
21
21
  | { kind: "load"; provider: string; modelId: string }
22
+ | { kind: "set-default"; provider: string; modelId: string }
23
+ | { kind: "defaults" }
22
24
  | { kind: "add" }
23
25
  | { kind: "update"; provider?: string; modelId?: string }
24
26
  | { kind: "delete"; provider?: string; modelId?: string }
27
+ | { kind: "options" }
25
28
  | { kind: "reset" }
26
29
  | { kind: "close" };
27
30
 
@@ -33,16 +36,23 @@ export interface RankedModel {
33
36
  lastUsed: number;
34
37
  contextWindow?: number;
35
38
  reasoning: boolean;
39
+ current?: boolean;
40
+ default?: boolean;
36
41
  }
37
42
 
38
43
  /**
39
44
  * Rank models by usage: most-selected first, ties broken by most-recent, then
40
45
  * alphabetically so the order is stable for never-used models.
41
46
  */
42
- export function rankModels(config: ModelsConfig): RankedModel[] {
47
+ export function rankModels(
48
+ config: ModelsConfig,
49
+ disabledProviders: readonly string[] = [],
50
+ ): RankedModel[] {
43
51
  const stats = loadUsage();
52
+ const disabled = new Set(disabledProviders);
44
53
  const rows: RankedModel[] = [];
45
54
  for (const [provider, entry] of Object.entries(config.providers ?? {})) {
55
+ if (disabled.has(provider)) continue;
46
56
  for (const m of entry.models ?? []) {
47
57
  const key = usageKey(provider, m.id);
48
58
  rows.push({
@@ -71,6 +81,8 @@ function formatContext(n?: number): string {
71
81
 
72
82
  export function toSelectItem(row: RankedModel): SelectItem {
73
83
  const bits: string[] = [row.modelId, `[${row.provider}]`];
84
+ if (row.current) bits.push("[current]");
85
+ if (row.default) bits.push("[default]");
74
86
  const ctxLabel = formatContext(row.contextWindow);
75
87
  if (ctxLabel) bits.push(`ctx ${ctxLabel}`);
76
88
  if (row.reasoning) bits.push("reasoning");
@@ -103,7 +115,7 @@ export function selectListTheme(theme: any): SelectListTheme {
103
115
  }
104
116
 
105
117
  const HINT =
106
- "↑↓ move • enter load • ctrl+a add • ctrl+u update • ctrl+x delete • ctrl+r reset stats • esc close";
118
+ "↑↓ move • enter load • ctrl+a add • ctrl+u update • ctrl+x delete • ctrl+d default • ctrl+g agent defaults • ctrl+o options • ctrl+r reset • esc close";
107
119
 
108
120
  export class ModelPicker extends Container implements Focusable {
109
121
  private search: Input;
@@ -117,6 +129,7 @@ export class ModelPicker extends Container implements Focusable {
117
129
  private theme: any;
118
130
  private onChange: () => void;
119
131
  private done: (action: PickerAction) => void;
132
+ private assignment: boolean;
120
133
 
121
134
  private _focused = false;
122
135
  get focused(): boolean {
@@ -133,11 +146,13 @@ export class ModelPicker extends Container implements Focusable {
133
146
  theme: any,
134
147
  onChange: () => void,
135
148
  done: (action: PickerAction) => void,
149
+ assignment = false,
136
150
  ) {
137
151
  super();
138
152
  this.theme = theme;
139
153
  this.onChange = onChange;
140
154
  this.done = done;
155
+ this.assignment = assignment;
141
156
  this.rows = rows;
142
157
  this.filtered = rows;
143
158
 
@@ -157,7 +172,7 @@ export class ModelPicker extends Container implements Focusable {
157
172
  this.list = this.buildList(rows);
158
173
 
159
174
  this.countText = new Text(fg("dim", this.countLabel()), 1, 0);
160
- this.hintText = new Text(fg("dim", HINT), 1, 0);
175
+ this.hintText = new Text(fg("dim", assignment ? "↑↓ move • enter assign • esc back" : HINT), 1, 0);
161
176
 
162
177
  this.addChild(this.search);
163
178
  this.addChild(this.list);
@@ -228,7 +243,15 @@ export class ModelPicker extends Container implements Focusable {
228
243
 
229
244
  handleInput(data: string): void {
230
245
  if (matchesKey(data, "escape")) return this.done({ kind: "close" });
246
+ if (!this.assignment && matchesKey(data, "ctrl+g")) return this.done({ kind: "defaults" });
247
+ if (!this.assignment && matchesKey(data, "ctrl+d")) {
248
+ const row = this.currentRow();
249
+ if (row) this.done({ kind: "set-default", provider: row.provider, modelId: row.modelId });
250
+ return;
251
+ }
252
+ if (this.assignment && ["ctrl+a", "ctrl+o", "ctrl+r", "ctrl+u", "ctrl+x", "ctrl+d", "ctrl+g"].some(key => matchesKey(data, key as any))) return;
231
253
  if (matchesKey(data, "ctrl+a")) return this.done({ kind: "add" });
254
+ if (matchesKey(data, "ctrl+o")) return this.done({ kind: "options" });
232
255
  if (matchesKey(data, "ctrl+r")) return this.done({ kind: "reset" });
233
256
 
234
257
  if (matchesKey(data, "ctrl+u")) {
@@ -1,19 +1,34 @@
1
1
  // Model configuration access + usage statistics store.
2
2
  //
3
- // Pi reads models.json itself. Better Model Select also supports an optional
4
- // models.local.json overlay for personal edits. The extension merges and
5
- // registers that overlay at runtime, so the local file remains private to BMS.
3
+ // Pi reads models.json itself. Better Model Select can also use a BMS-owned
4
+ // models.bms.json overlay. The extension merges and registers that overlay at
5
+ // runtime, so Pi never needs to understand the extra file.
6
6
 
7
7
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
8
8
  import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
 
11
- export type ConfigTarget = "models.json" | "models.local.json";
11
+ export type ConfigTarget = "models.json" | "models.bms.json";
12
+ export type WriteMode = "overlay" | "direct";
12
13
 
13
14
  export const MODELS_PATH = (): string => join(getAgentDir(), "models.json");
14
- export const LOCAL_MODELS_PATH = (): string => join(getAgentDir(), "models.local.json");
15
+ export const BMS_MODELS_PATH = (): string => join(getAgentDir(), "models.bms.json");
16
+ export const LEGACY_LOCAL_MODELS_PATH = (): string => join(getAgentDir(), "models.local.json");
17
+ export const SETTINGS_PATH = (): string => join(getAgentDir(), "bms-settings.json");
15
18
  export const USAGE_PATH = (): string => join(getAgentDir(), "bms-usage.json");
16
19
 
20
+ export interface BmsSettings {
21
+ version: 1;
22
+ writeMode: WriteMode;
23
+ disabledProviders: string[];
24
+ }
25
+
26
+ const DEFAULT_SETTINGS: BmsSettings = {
27
+ version: 1,
28
+ writeMode: "overlay",
29
+ disabledProviders: [],
30
+ };
31
+
17
32
  export interface ModelEntry {
18
33
  id: string;
19
34
  /** BMS overlay tombstone. Hides an inherited model without editing models.json. */
@@ -41,7 +56,37 @@ export interface ModelsConfig {
41
56
  }
42
57
 
43
58
  export function configPath(target: ConfigTarget): string {
44
- return target === "models.local.json" ? LOCAL_MODELS_PATH() : MODELS_PATH();
59
+ return target === "models.bms.json" ? BMS_MODELS_PATH() : MODELS_PATH();
60
+ }
61
+
62
+ /** Move the old BMS overlay name to the explicit BMS-owned filename. */
63
+ export function migrateLegacyOverlay(): void {
64
+ if (!existsSync(BMS_MODELS_PATH()) && existsSync(LEGACY_LOCAL_MODELS_PATH())) {
65
+ renameSync(LEGACY_LOCAL_MODELS_PATH(), BMS_MODELS_PATH());
66
+ }
67
+ }
68
+
69
+ export function loadSettings(): BmsSettings {
70
+ try {
71
+ const raw = JSON.parse(readFileSync(SETTINGS_PATH(), "utf8")) as Partial<BmsSettings>;
72
+ return {
73
+ version: 1,
74
+ writeMode: raw.writeMode === "direct" ? "direct" : "overlay",
75
+ disabledProviders: Array.isArray(raw.disabledProviders)
76
+ ? raw.disabledProviders.filter((value): value is string => typeof value === "string")
77
+ : [],
78
+ };
79
+ } catch {
80
+ return structuredClone(DEFAULT_SETTINGS);
81
+ }
82
+ }
83
+
84
+ export function saveSettings(settings: BmsSettings): void {
85
+ writeJsonAtomic(SETTINGS_PATH(), settings);
86
+ }
87
+
88
+ export function writeTarget(settings = loadSettings()): ConfigTarget {
89
+ return settings.writeMode === "direct" ? "models.json" : "models.bms.json";
45
90
  }
46
91
 
47
92
  /** Write JSON atomically so a crash mid-write cannot truncate the target file. */
@@ -60,6 +105,7 @@ export function loadConfigFile(
60
105
  target: ConfigTarget,
61
106
  missingIsEmpty = false,
62
107
  ): ModelsConfig | null {
108
+ if (target === "models.bms.json") migrateLegacyOverlay();
63
109
  const path = configPath(target);
64
110
  if (missingIsEmpty && !existsSync(path)) return { providers: {} };
65
111
  try {
@@ -70,7 +116,7 @@ export function loadConfigFile(
70
116
  }
71
117
  }
72
118
 
73
- /** Merge models.local.json over models.json. Model entries merge by id. */
119
+ /** Merge models.bms.json over models.json. Model entries merge by id. */
74
120
  export function mergeConfigs(base: ModelsConfig, local: ModelsConfig): ModelsConfig {
75
121
  const merged = structuredClone(base);
76
122
  merged.providers = merged.providers ?? {};
@@ -100,11 +146,13 @@ export function mergeConfigs(base: ModelsConfig, local: ModelsConfig): ModelsCon
100
146
  export function loadConfig(ctx: {
101
147
  ui: { notify: (m: string, l: string) => void };
102
148
  }): ModelsConfig | null {
103
- const base = loadConfigFile(ctx, "models.json");
149
+ const base = loadConfigFile(ctx, "models.json", true);
104
150
  if (!base) return null;
105
- if (!existsSync(LOCAL_MODELS_PATH())) return base;
106
- const local = loadConfigFile(ctx, "models.local.json");
107
- return local ? mergeConfigs(base, local) : null;
151
+ if (loadSettings().writeMode === "direct") return base;
152
+ migrateLegacyOverlay();
153
+ if (!existsSync(BMS_MODELS_PATH())) return base;
154
+ const overlay = loadConfigFile(ctx, "models.bms.json");
155
+ return overlay ? mergeConfigs(base, overlay) : null;
108
156
  }
109
157
 
110
158
  export function saveConfig(config: ModelsConfig, target: ConfigTarget): void {
@@ -119,7 +167,7 @@ export function configContainsModel(
119
167
  return Boolean(config.providers?.[provider]?.models?.some((model) => model.id === modelId));
120
168
  }
121
169
 
122
- /** Hide an inherited model through the BMS local overlay. */
170
+ /** Hide an inherited model through the BMS overlay. */
123
171
  export function tombstoneModel(config: ModelsConfig, provider: string, modelId: string): void {
124
172
  upsertModel(config, provider, { id: modelId, bmsDeleted: true });
125
173
  }
package/package.json CHANGED
@@ -1,14 +1,17 @@
1
1
  {
2
2
  "name": "pi-better-model-select",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
- "description": "Better model select for pi — /bms opens a searchable model picker sorted by how often you actually use each model, with inline add, update and delete.",
5
+ "description": "Searchable, usage-ranked model picker for Pi with model management, default model and thinking effort selection, and per-role subagent defaults.",
6
6
  "keywords": [
7
7
  "pi-package",
8
8
  "pi-extension",
9
9
  "bms",
10
10
  "model-select",
11
11
  "model-manager",
12
+ "model-defaults",
13
+ "thinking-effort",
14
+ "subagents",
12
15
  "model-picker",
13
16
  "search",
14
17
  "fuzzy",
@@ -22,6 +25,8 @@
22
25
  "extensions/",
23
26
  "!extensions/**/*.test.ts",
24
27
  "README.md",
28
+ "docs/configuration.md",
29
+ "docs/development.md",
25
30
  "LICENSE"
26
31
  ],
27
32
  "pi": {
@@ -31,6 +36,7 @@
31
36
  },
32
37
  "scripts": {
33
38
  "typecheck": "tsc --noEmit",
39
+ "test:smoke": "python3 scripts/smoke-local.py",
34
40
  "test": "node --experimental-strip-types --test extensions/lib/*.test.ts",
35
41
  "check:pack": "npm pack --dry-run --ignore-scripts >/dev/null",
36
42
  "gate": "npm run typecheck && npm test && npm run check:pack"