pi-better-model-select 0.3.3 → 0.3.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.4
4
+
5
+ ### Fixed
6
+
7
+ - Streamline Ctrl+O into a BMS navigation menu with Models, Add model, Defaults, and Options. Keep Ctrl+A visible on model search and add Ctrl+E for model effort.
8
+ - Share the startup-default form between Ctrl+D and Defaults, with explicit Apply now. Separate model capabilities from preferred effort and group agent roles under Defaults.
9
+ - Separate packaged user documentation in docs/public from internal development and menu-design documents in docs/internal.
10
+ - Save Ctrl+D effort choices per model without changing the global fallback or other model preferences. Configure fallback effort in BMS Defaults instead, in user or trusted-project scope.
11
+ - Replace status text with separate DEFAULT and CURRENT checkmark columns.
12
+ - Show an EFFORT column for every search result before loading, using saved preferences or the session fallback. Non-reasoning models show off.
13
+ - Apply saved model effort preferences immediately when loading through BMS, without relying on Pi's cached settings or requiring a reload. Trusted project preferences still take priority.
14
+ - Update the smoke test for the save notification's settings path.
15
+
3
16
  ## 0.3.3
4
17
 
5
18
  ### Fixed
package/README.md CHANGED
@@ -12,9 +12,9 @@ Run `/bms`, type to filter by name, model ID, or provider, then press Enter to s
12
12
 
13
13
  ## Compact TUI
14
14
 
15
- ![BMS model search, forms, defaults, and result placement](https://cdn.jsdelivr.net/npm/pi-better-model-select@0.3.1/docs/demo/showcase.gif)
15
+ ![BMS model search, forms, defaults, and result placement](https://cdn.jsdelivr.net/npm/pi-better-model-select@latest/docs/demo/showcase.gif)
16
16
 
17
- The picker uses aligned model, provider, context, usage, and status columns. Type in the search field in Pi's editor area. Results appear below search by default, matching Pi command completion. Choose **Options → Result placement → Above prompt** for bottom-up results with the best match nearest the search line.
17
+ The picker uses aligned model, provider, effort, context, usage, default, and current columns. Type in the search field in Pi's editor area. Results appear below search by default, matching Pi command completion. Choose **Options → Result placement → Above prompt** for bottom-up results with the best match nearest the search line.
18
18
 
19
19
  Add and edit use one form. Review the fields, press Enter to change a value, then choose **Save configuration** or press Ctrl+S to save. Text and numeric fields currently open a Pi input dialog; choices use the compact table. Esc goes back; unsaved changes require a discard confirmation. Options also stay in a draft until saved.
20
20
 
@@ -33,26 +33,28 @@ Inside `/bms`:
33
33
  | --- | --- |
34
34
  | ↑ / ↓ | Move through models |
35
35
  | Enter | Use the selected model for this session |
36
- | Ctrl+D | Choose effort, save as the Pi default, and use it now |
36
+ | Ctrl+D | Open startup-default form for the highlighted model |
37
+ | Ctrl+E | Edit the highlighted model's effort preference |
37
38
  | Ctrl+G | Open the defaults menu |
38
39
  | Ctrl+A / Ctrl+U / Ctrl+X | Add / update / delete a model |
39
- | Ctrl+O | Open actions, including options and defaults |
40
+ | Ctrl+O | Open BMS menu: Models, Add model, Defaults, Options |
40
41
  | Ctrl+R | Reset usage ranking |
41
42
  | Esc | Close |
42
43
 
43
- The status column identifies the active model and saved default. Only Enter selections count toward usage ranking. Returning from a submenu preserves your search and selection. Plain menus always browse top-to-bottom; only searchable result lists reverse in above-prompt mode. Esc goes back one level, including back to Actions when that is where you entered a submenu.
44
+ The DEFAULT and CURRENT columns use checkmarks to identify the saved startup default and active session model. The row highlight shows the model you are browsing. The EFFORT column shows the effort each model will use when loaded, using its saved preference or the current session level as a fallback. Non-reasoning models show `off`. Pi may clamp unsupported effort levels. Only Enter selections count toward usage ranking. Returning from a submenu preserves your search and selection. Plain menus always browse top-to-bottom; only searchable result lists reverse in above-prompt mode. Esc goes back one level, including back to the BMS menu when that is where you entered a submenu.
44
45
 
45
46
  ## Default models and effort
46
47
 
47
48
  Save a model and thinking effort for Pi, a subagent fallback, or an individual agent role.
48
49
 
49
- - Use Ctrl+D in `/bms` for the main Pi default.
50
+ - Use Ctrl+D in `/bms` to set the main Pi default and save effort for that model only. Other models' preferences and the global fallback stay unchanged.
51
+ - Configure the user-wide fallback effort in `/bms-defaults` → Pi fallback effort. Per-model preferences take priority.
50
52
  - Use `/bms-defaults` for all defaults. The menu starts in user scope; select the scope row to switch to a trusted project's settings.
51
53
  - Agent model and effort overrides are independent. You can inherit the parent model while choosing a different effort.
52
54
 
53
55
  Removing an override restores the agent's own definition and fallback rules. Effort `off` is explicit, not inheritance. The menu shows saved settings and their sources, not running-child state.
54
56
 
55
- Run `/reload` after changing agent defaults or per-model effort preferences. Existing children stay unchanged. Test Pi startup defaults in a fresh session, since resumed sessions restore their recorded model and effort.
57
+ BMS applies saved per-model effort preferences when you load a model, without `/reload`. Run `/reload` after changing agent defaults or before using updated per-model preferences through Pi's own model picker. Existing children stay unchanged. Test Pi startup defaults in a fresh session, since resumed sessions restore their recorded model and effort.
56
58
 
57
59
  ### Optional pi-subagents integration
58
60
 
@@ -74,7 +76,7 @@ BMS uses provider-reported effort lists when available and Pi's capability restr
74
76
  off · minimal · low · medium · high · xhigh · max
75
77
  ```
76
78
 
77
- Add/update also lets you edit supported levels and set a per-model preferred effort. These hints do not change Pi's provider adapters. If Pi clamps an effort, BMS asks before saving the applied level. No paid model probes are made.
79
+ Add/update edits supported effort levels. Ctrl+E edits a per-model preferred effort separately. These hints do not change Pi's provider adapters. If Pi clamps an effort, BMS asks before saving the applied level. No paid model probes are made.
78
80
 
79
81
  ## Where changes are saved
80
82
 
@@ -92,8 +94,8 @@ Provider filters hide entries only in `/bms`, not Pi's `/model` picker. Default-
92
94
  ## Further documentation
93
95
 
94
96
  - [Changelog](https://cdn.jsdelivr.net/npm/pi-better-model-select@latest/CHANGELOG.md): changes by version.
95
- - [Configuration reference](https://cdn.jsdelivr.net/npm/pi-better-model-select@0.3.2/docs/configuration.md): file paths, precedence, overlays, metadata, and integration limits.
96
- - [Development and testing](https://cdn.jsdelivr.net/npm/pi-better-model-select@0.3.2/docs/development.md): local loading, isolated tests, checks, and publishing.
97
+ - [Configuration reference](https://cdn.jsdelivr.net/npm/pi-better-model-select@latest/docs/public/configuration.md): file paths, precedence, overlays, metadata, and integration limits.
98
+ Development and navigation design documents live in `docs/internal/` in the source checkout and are not included in the npm package.
97
99
 
98
100
  ## License
99
101
 
Binary file
@@ -48,14 +48,14 @@ Default settings are independent of the model write mode.
48
48
  | Setting | Native Pi settings field |
49
49
  | --- | --- |
50
50
  | Pi model | `defaultProvider`, `defaultModel` |
51
- | Pi effort | `defaultThinkingLevel` |
51
+ | Global fallback effort, configured in BMS Defaults | `defaultThinkingLevel` |
52
52
  | Per-model preferred effort | `modelThinkingLevels["provider/model"]` |
53
53
  | Subagent fallback | `subagents.defaultModel`, `subagents.defaultThinking` |
54
54
  | Role override | `subagents.agentOverrides.<name>.model` and `.thinking` |
55
55
 
56
56
  The defaults menu starts in user scope. Project scope requires a trusted project. Pi defaults use the working directory's `.pi/settings.json`. Agent defaults use the project root resolved by `pi-subagents`, which may differ in nested repositories. The menu shows the paths.
57
57
 
58
- Project settings and provider-specific role overrides may take precedence. BMS shows override sources and warns when a saved Pi default would be shadowed. Saving a Pi default removes the selected model's per-model effort preference in the same scope, so it cannot override the new default.
58
+ Project settings and provider-specific role overrides may take precedence. BMS shows override sources and warns when a saved Pi default would be shadowed. Saving a Pi default sets the selected model's `modelThinkingLevels` preference in the chosen scope without changing `defaultThinkingLevel` or other models' preferences. Automatic removes only the selected model's preference. Configure fallback effort in BMS Defaults. The scope selector chooses user or trusted-project settings.
59
59
 
60
60
  For agent roles:
61
61
 
package/extensions/bms.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // to the search box.
6
6
 
7
7
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
- import { defaultsFlow, piPreference, setMainDefault } from "./lib/defaults.ts";
8
+ import { defaultsFlow, editPiDefault, editModelPreference, modelEffort, piPreference } from "./lib/defaults.ts";
9
9
  import { readSettings, settingsPath } from "./lib/defaults-store.ts";
10
10
  import { pickerDialog } from "./lib/picker-dialog.ts";
11
11
  import { confirmAction, editForm, tableSelect } from "./lib/tui.ts";
@@ -80,12 +80,12 @@ function syncLocalConfig(pi: ExtensionAPI, ctx: any): ModelsConfig | null {
80
80
  }
81
81
 
82
82
  /** Stage option changes until the user explicitly saves. */
83
- async function optionsFlow(ctx: any, config: ModelsConfig): Promise<void> {
83
+ export async function optionsFlow(ctx: any, config: ModelsConfig): Promise<void> {
84
84
  const settings = loadSettings();
85
85
  const providers = Object.keys(config.providers ?? {}).sort();
86
86
  const writeLabel = () => settings.writeMode === "overlay" ? "BMS overlay (models.bms.json)" : "Pi config (models.json)";
87
87
  await editForm(ctx, {
88
- title: "Models / Options",
88
+ title: "BMS / Options",
89
89
  fields: [
90
90
  { key: "placement", label: "Result placement", value: settings.resultPlacement === "above" ? "Above prompt" : "Below prompt",
91
91
  description: "The best match stays nearest the search line. Arrow keys follow the displayed direction.",
@@ -105,6 +105,10 @@ async function optionsFlow(ctx: any, config: ModelsConfig): Promise<void> {
105
105
  settings.writeMode = value as "overlay" | "direct";
106
106
  return writeLabel();
107
107
  } },
108
+ { key: "reset", label: "Reset usage ranking", value: "", edit: async () => {
109
+ if (await confirmAction(ctx, { title: "BMS / Options / Reset usage", lines: ["Clear all model usage counts? This takes effect immediately."], confirmLabel: "Reset usage" })) saveUsage({ version: 1, counts: {}, lastUsed: {} });
110
+ return;
111
+ } },
108
112
  { key: "providers", label: "Providers", value: `${providers.filter(p => !settings.disabledProviders.includes(p)).length} enabled`,
109
113
  description: "Choose a provider to toggle visibility. Esc returns to options; Ctrl+S saves the complete draft.",
110
114
  edit: async () => {
@@ -125,7 +129,7 @@ async function optionsFlow(ctx: any, config: ModelsConfig): Promise<void> {
125
129
  });
126
130
  }
127
131
 
128
- async function loadModel(
132
+ export async function loadModel(
129
133
  pi: ExtensionAPI,
130
134
  ctx: any,
131
135
  provider: string,
@@ -147,9 +151,15 @@ async function loadModel(
147
151
  return;
148
152
  }
149
153
 
154
+ // Read before switching so malformed settings cannot leave a partial switch.
155
+ // Pi caches settings; BMS preference edits on disk must apply without /reload.
156
+ const user = readSettings(settingsPath(ctx.cwd, "user"));
157
+ const project = ctx.isProjectTrusted?.() ? readSettings(settingsPath(ctx.cwd, "project")) : {};
158
+ const effort = modelEffort(user, project, `${provider}/${modelId}`) ?? pi.getThinkingLevel();
150
159
  // pi.setModel lives on the ExtensionAPI, not on the command context.
151
160
  const ok = await pi.setModel(model);
152
161
  if (ok) {
162
+ pi.setThinkingLevel(effort);
153
163
  // Counted here rather than in model_select so /bms picks are ranked but
154
164
  // session restores are not, which would otherwise inflate every startup.
155
165
  recordUsage(provider, modelId);
@@ -171,7 +181,8 @@ export default function (pi: ExtensionAPI) {
171
181
  try { await action(); } catch (error) { ctx.ui.notify(`BMS: ${error}`, "error"); }
172
182
  };
173
183
  const addModel = async (ctx: any) => addFlow(ctx, async (provider, modelId) => {
174
- if (syncLocalConfig(pi, ctx)) await report(ctx, () => setMainDefault(pi, ctx, provider, modelId));
184
+ const config = syncLocalConfig(pi, ctx);
185
+ if (config) await report(ctx, () => editPiDefault(pi, ctx, config, "user", readSettings(settingsPath(ctx.cwd, "user")), ctx.isProjectTrusted?.() ? readSettings(settingsPath(ctx.cwd, "project")) : {}, { provider, modelId }));
175
186
  });
176
187
  pi.registerCommand("bms-options", {
177
188
  description: "Configure BMS result placement, write mode, and provider visibility",
@@ -235,9 +246,13 @@ export default function (pi: ExtensionAPI) {
235
246
  }
236
247
 
237
248
  try {
238
- const preference = piPreference(readSettings(settingsPath(ctx.cwd, "user")), ctx.isProjectTrusted?.() ? readSettings(settingsPath(ctx.cwd, "project")) : {});
249
+ const user = readSettings(settingsPath(ctx.cwd, "user"));
250
+ const project = ctx.isProjectTrusted?.() ? readSettings(settingsPath(ctx.cwd, "project")) : {};
251
+ const preference = piPreference(user, project);
239
252
  for (const row of rows) {
240
253
  row.current = ctx.model?.provider === row.provider && ctx.model?.id === row.modelId;
254
+ const model = ctx.modelRegistry.find(row.provider, row.modelId);
255
+ row.effort = (model?.reasoning ?? row.reasoning) ? modelEffort(user, project, `${row.provider}/${row.modelId}`) ?? pi.getThinkingLevel() : "off";
241
256
  row.default = preference.provider === row.provider && preference.model === row.modelId;
242
257
  }
243
258
  } catch (error) { ctx.ui.notify(`Cannot read defaults: ${error}`, "warning"); }
@@ -248,7 +263,10 @@ export default function (pi: ExtensionAPI) {
248
263
  await loadModel(pi, ctx, action.provider, action.modelId);
249
264
  return;
250
265
  case "set-default":
251
- await report(ctx, () => setMainDefault(pi, ctx, action.provider, action.modelId));
266
+ await report(ctx, () => editPiDefault(pi, ctx, config, "user", readSettings(settingsPath(ctx.cwd, "user")), ctx.isProjectTrusted?.() ? readSettings(settingsPath(ctx.cwd, "project")) : {}, action));
267
+ break;
268
+ case "effort":
269
+ await report(ctx, () => editModelPreference(pi, ctx, action.provider, action.modelId));
252
270
  break;
253
271
  case "defaults":
254
272
  await report(ctx, () => defaultsFlow(pi, ctx, config));
@@ -67,12 +67,12 @@ export function saveCapability(key: string, capability: Capability, path = metad
67
67
  export function setPiDefault(data: JsonObject, provider: string, model: string, effort: Effort | undefined): void {
68
68
  data.defaultProvider = provider;
69
69
  data.defaultModel = model;
70
- if (effort === undefined) delete data.defaultThinkingLevel;
71
- else data.defaultThinkingLevel = effort;
72
- // A per-model startup preference would otherwise beat the newly chosen default.
73
- if (data.modelThinkingLevels !== undefined) {
74
- const levels = object(data.modelThinkingLevels);
75
- delete levels[`${provider}/${model}`];
70
+ // Choosing one model must not change the fallback for every other model.
71
+ if (effort !== undefined) {
72
+ const levels = data.modelThinkingLevels === undefined ? data.modelThinkingLevels = {} : object(data.modelThinkingLevels);
73
+ levels[`${provider}/${model}`] = effort;
74
+ } else if (data.modelThinkingLevels !== undefined) {
75
+ delete object(data.modelThinkingLevels)[`${provider}/${model}`];
76
76
  }
77
77
  }
78
78
  export function setRoleField(data: JsonObject, role: string | undefined, field: "model" | "thinking", value: string | false | undefined): void {
@@ -1,5 +1,5 @@
1
1
  import { discoverRoles, type AgentRole } from "./agents.ts";
2
- import { capabilities, isEffort, patchSettings, readSettings, roleField, setPiDefault, setRoleField, settingsPath, type Effort, type JsonObject, type Scope } from "./defaults-store.ts";
2
+ import { EFFORTS, capabilities, isEffort, patchSettings, readSettings, roleField, setPiDefault, setRoleField, settingsPath, type Effort, type JsonObject, type Scope } from "./defaults-store.ts";
3
3
  import { tableSelect, editForm, confirmAction } from "./tui.ts";
4
4
  import { rankModelsForDefaults } from "./defaults-models.ts";
5
5
  import type { ModelsConfig } from "./store.ts";
@@ -17,9 +17,13 @@ export function piPreference(user: JsonObject, project: JsonObject): { provider?
17
17
  const provider = project.defaultProvider ?? user.defaultProvider;
18
18
  const model = project.defaultModel ?? user.defaultModel;
19
19
  const key = `${provider}/${model}`;
20
- const effort = project.modelThinkingLevels?.[key] ?? user.modelThinkingLevels?.[key] ?? project.defaultThinkingLevel ?? user.defaultThinkingLevel;
20
+ const effort = modelEffort(user, project, key);
21
21
  return { provider, model, effort, source: project.defaultModel !== undefined || project.defaultProvider !== undefined ? "Project" : "User / automatic" };
22
22
  }
23
+ export function modelEffort(user: JsonObject, project: JsonObject, key: string): Effort | undefined {
24
+ const value = project.modelThinkingLevels?.[key] ?? user.modelThinkingLevels?.[key] ?? project.defaultThinkingLevel ?? user.defaultThinkingLevel;
25
+ return isEffort(value) ? value : undefined;
26
+ }
23
27
  function trusted(pi: any, ctx: any): boolean {
24
28
  return typeof ctx.isProjectTrusted === "function" ? ctx.isProjectTrusted() : typeof pi.isProjectTrusted === "function" && pi.isProjectTrusted();
25
29
  }
@@ -34,14 +38,14 @@ function confirmDefault(ctx: any, title: string, message: string, confirmLabel:
34
38
  export async function setMainDefault(pi: any, ctx: any, provider: string, modelId: string, scope: Scope = "user"): Promise<void> {
35
39
  await applyMainDefault(pi, ctx, provider, modelId, scope);
36
40
  }
37
- async function applyMainDefault(pi: any, ctx: any, provider: string, modelId: string, scope: Scope, selectedEffort?: Effort | "automatic"): Promise<boolean> {
41
+ async function applyMainDefault(pi: any, ctx: any, provider: string, modelId: string, scope: Scope, selectedEffort?: Effort | "automatic", applyNow = true): Promise<boolean> {
38
42
  if (scope === "project" && !trusted(pi, ctx)) throw new Error("Project is not trusted");
39
43
  await ctx.modelRegistry.refresh();
40
44
  const model = ctx.modelRegistry.find(provider, modelId);
41
45
  if (!model) throw new Error(`Model ${provider}/${modelId} is not in the active registry`);
42
46
  const user = readSettings(settingsPath(ctx.cwd, "user"));
43
47
  const project = projectSettings(pi, ctx, ctx.cwd);
44
- const choice = selectedEffort ?? await chooseEffort(ctx, `${provider}/${modelId}`, model, piPreference(user, project).effort);
48
+ const choice = selectedEffort ?? await chooseEffort(ctx, `${provider}/${modelId}`, model, modelEffort(user, project, `${provider}/${modelId}`));
45
49
  if (choice === undefined) return false;
46
50
  const effort = choice === "automatic" ? undefined : choice;
47
51
  const preview = structuredClone(scope === "user" ? user : project);
@@ -58,6 +62,11 @@ async function applyMainDefault(pi: any, ctx: any, provider: string, modelId: st
58
62
  } else if (selectedEffort === undefined && !(await confirmDefault(ctx, "Save and use Pi default?", summary, "Save and use"))) return false;
59
63
  // Parse before changing session state. A malformed file must never be overwritten.
60
64
  readSettings(settingsPath(ctx.cwd, scope));
65
+ if (!applyNow) {
66
+ patchSettings(settingsPath(ctx.cwd, scope), data => setPiDefault(data, provider, modelId, effort));
67
+ ctx.ui.notify(`Saved ${scope} startup default. Current session unchanged.`, "info");
68
+ return true;
69
+ }
61
70
  const previousModel = ctx.model;
62
71
  const previousEffort = pi.getThinkingLevel();
63
72
  if (!(await pi.setModel(model))) {
@@ -95,21 +104,22 @@ async function assignModel(ctx: any, config: ModelsConfig, title: string): Promi
95
104
  const row = value === undefined ? undefined : rows[Number(value)];
96
105
  return row ? { provider: row.provider, modelId: row.modelId } : undefined;
97
106
  }
98
- async function editPiDefault(pi: any, ctx: any, config: ModelsConfig, scope: Scope, user: JsonObject, project: JsonObject): Promise<void> {
107
+ export async function editPiDefault(pi: any, ctx: any, config: ModelsConfig, scope: Scope, user: JsonObject, project: JsonObject, initial?: { provider: string; modelId: string }): Promise<void> {
99
108
  const current = piPreference(user, project);
100
- let provider = current.provider, modelId = current.model;
101
- let effort: Effort | "automatic" = current.effort ?? "automatic";
109
+ let provider = initial?.provider ?? current.provider, modelId = initial?.modelId ?? current.model;
110
+ let effort: Effort | "automatic" = modelEffort(user, project, `${provider}/${modelId}`) ?? "automatic";
111
+ let applyNow = false;
102
112
  let reset = false;
103
113
  const path = settingsPath(ctx.cwd, scope);
104
114
  await editForm(ctx, {
105
- title: "Pi session default",
106
- hint: `Scope: ${scope}\n${path}\nSave switches the session after validating the model. Higher-priority settings still apply at startup.`,
115
+ title: "BMS / Defaults / Pi startup default",
116
+ hint: `Scope: ${scope}\n${path}\nSave sets the startup default. Apply now explicitly switches the session.`,
107
117
  fields: [
108
118
  { key: "model", label: "Model", value: `${provider ?? "auto"}/${modelId ?? "auto"}`, edit: async () => {
109
- const action = await tableSelect(ctx, { title: "Pi default model", rows: [{ value: "choose", label: "Choose model" }, { value: "reset", label: "Remove scope defaults", description: "Remove this scope's default model and global effort. Keep other per-model preferences." }] });
119
+ const action = await tableSelect(ctx, { title: "Pi default model", rows: [{ value: "choose", label: "Choose model" }, { value: "reset", label: "Remove scope defaults", description: "Remove this scope's default model. Keep per-model preferences and global fallback effort." }] });
110
120
  if (action === undefined) return;
111
121
  if (action === "reset") {
112
- if (!(await confirmAction(ctx, { title: "Reset Pi defaults?", lines: [`Scope: ${scope}`, path, "Remove defaultProvider, defaultModel and defaultThinkingLevel only. Session stays unchanged."], confirmLabel: "Reset scope defaults" }))) return;
122
+ if (!(await confirmAction(ctx, { title: "Reset Pi defaults?", lines: [`Scope: ${scope}`, path, "Remove defaultProvider and defaultModel only. Effort preferences and session stay unchanged."], confirmLabel: "Reset scope defaults" }))) return;
113
123
  reset = true; return "remove scope defaults";
114
124
  }
115
125
  const selected = await assignModel(ctx, config, "Choose Pi default model");
@@ -123,15 +133,74 @@ async function editPiDefault(pi: any, ctx: any, config: ModelsConfig, scope: Sco
123
133
  if (selected === undefined) return;
124
134
  effort = selected; return selected;
125
135
  } },
136
+ { key: "apply", label: "Apply now", value: "no", edit: async () => {
137
+ const value = await tableSelect(ctx, { title: "BMS / Defaults / Apply now", search: false, rows: ["no", "yes"].map(value => ({ value, label: value })) });
138
+ if (value === undefined) return;
139
+ applyNow = value === "yes"; return value;
140
+ } },
126
141
  ],
127
142
  onSave: async () => {
128
143
  if (scope === "project" && !trusted(pi, ctx)) throw new Error("Project is not trusted");
129
144
  if (reset) {
130
- patchSettings(path, data => { delete data.defaultProvider; delete data.defaultModel; delete data.defaultThinkingLevel; });
145
+ patchSettings(path, data => { delete data.defaultProvider; delete data.defaultModel; });
131
146
  return true;
132
147
  }
133
148
  if (!provider || !modelId) { ctx.ui.notify("Choose a model first.", "warning"); return false; }
134
- return applyMainDefault(pi, ctx, provider, modelId, scope, effort);
149
+ return applyMainDefault(pi, ctx, provider, modelId, scope, effort, applyNow);
150
+ },
151
+ });
152
+ }
153
+ export async function editModelPreference(pi: any, ctx: any, provider: string, modelId: string): Promise<void> {
154
+ let scope: Scope = "user";
155
+ const key = `${provider}/${modelId}`;
156
+ const initial = readSettings(settingsPath(ctx.cwd, scope)).modelThinkingLevels?.[key];
157
+ let effort: Effort | "automatic" = isEffort(initial) ? initial : "automatic";
158
+ await editForm(ctx, {
159
+ title: `BMS / Models / ${key} / Effort`,
160
+ hint: "Save changes only this model's preference, not the startup model or global fallback.",
161
+ fields: [
162
+ { key: "scope", label: "Scope", value: scope, edit: async () => {
163
+ if (!trusted(pi, ctx)) { ctx.ui.notify("Project preferences require trust.", "warning"); return; }
164
+ scope = scope === "user" ? "project" : "user"; return scope;
165
+ } },
166
+ { key: "effort", label: "Preferred effort", value: effort, edit: async () => {
167
+ const value = await chooseEffort(ctx, key, ctx.modelRegistry.find(provider, modelId), effort);
168
+ if (value === undefined) return;
169
+ effort = value; return value;
170
+ } },
171
+ ],
172
+ onSave: async () => {
173
+ if (scope === "project" && !trusted(pi, ctx)) throw new Error("Project is not trusted");
174
+ patchSettings(settingsPath(ctx.cwd, scope), data => {
175
+ const levels = data.modelThinkingLevels ??= {};
176
+ if (effort === "automatic") delete levels[key]; else levels[key] = effort;
177
+ });
178
+ return true;
179
+ },
180
+ });
181
+ }
182
+ export async function editFallbackEffort(pi: any, ctx: any, scope: Scope): Promise<void> {
183
+ if (scope === "project" && !trusted(pi, ctx)) throw new Error("Project is not trusted");
184
+ const path = settingsPath(ctx.cwd, scope);
185
+ const saved = readSettings(path).defaultThinkingLevel;
186
+ let fallback = isEffort(saved) ? saved : "automatic";
187
+ await editForm(ctx, {
188
+ title: "BMS / Defaults / Pi fallback effort",
189
+ hint: `Scope: ${scope}\n${path}\nUsed only when a model has no per-model preference. Automatic removes this scope's fallback.`,
190
+ fields: [{ key: "fallback", label: "Fallback effort", value: fallback, edit: async () => {
191
+ const value = await tableSelect(ctx, { title: "Pi fallback effort / Choose level", initialValue: fallback,
192
+ rows: ["automatic", ...EFFORTS].map(value => ({ value, label: value })) });
193
+ if (value === undefined) return;
194
+ fallback = value; return value;
195
+ } }],
196
+ onSave: async () => {
197
+ if (scope === "project" && !trusted(pi, ctx)) throw new Error("Project is not trusted");
198
+ patchSettings(path, data => {
199
+ if (isEffort(fallback)) data.defaultThinkingLevel = fallback;
200
+ else delete data.defaultThinkingLevel;
201
+ });
202
+ ctx.ui.notify(`Saved ${scope} fallback effort. Per-model preferences are unchanged.`, "info");
203
+ return true;
135
204
  },
136
205
  });
137
206
  }
@@ -149,20 +218,17 @@ export async function defaultsFlow(pi: any, ctx: any, config: ModelsConfig): Pro
149
218
  const modelFallback = roleField(agentUser, agentProject, ctx.model?.provider, undefined, "model");
150
219
  const thinkingFallback = roleField(agentUser, agentProject, ctx.model?.provider, undefined, "thinking");
151
220
  const selected = await tableSelect(ctx, {
152
- title: "BMS defaults",
221
+ title: "BMS / Defaults",
153
222
  search: false,
154
223
  state,
155
- hint: `Scope: ${scope}. Saved settings, not running children. Agent changes require /reload.\nPi: ${settingsPath(ctx.cwd, scope)}\nAgents: ${settingsPath(discovery.projectRoot, scope)}`,
224
+ description: `Scope: ${scope}. Saved settings, not running children. Agent changes require /reload.\nPi: ${settingsPath(ctx.cwd, scope)}\nAgents: ${settingsPath(discovery.projectRoot, scope)}`,
156
225
  columns: [{ key: "target", label: "TARGET" }, { key: "model", label: "MODEL" }, { key: "effort", label: "EFFORT" }, { key: "source", label: "SOURCE" }],
157
226
  rows: [
158
227
  { value: "0", label: `Scope: ${scope}`, cells: { target: "Scope", model: scope, effort: "", source: "Select to change" } },
159
- { value: "1", label: "Pi session", cells: { target: "Pi session", model: `${main.provider ?? "auto"}/${main.model ?? "auto"}`, effort: main.effort ?? "automatic", source: main.source } },
228
+ { value: "1", label: "Pi startup default", cells: { target: "Pi startup default", model: `${main.provider ?? "auto"}/${main.model ?? "auto"}`, effort: main.effort ?? "automatic", source: main.source } },
229
+ { value: "fallback", label: "Pi fallback effort", cells: { target: "Pi fallback effort", model: "Models without a preference", effort: project.defaultThinkingLevel ?? user.defaultThinkingLevel ?? "automatic", source: project.defaultThinkingLevel !== undefined ? "Project" : "User / automatic" } },
160
230
  { value: "2", label: "Subagent fallback", cells: { target: "Subagent fallback", model: display(modelFallback.value), effort: display(thinkingFallback.value), source: `${modelFallback.source}; ${thinkingFallback.source}` } },
161
- ...roles.map((role, index) => {
162
- const model = roleField(agentUser, agentProject, ctx.model?.provider, role.name, "model");
163
- const effort = roleField(agentUser, agentProject, ctx.model?.provider, role.name, "thinking");
164
- return { value: String(index + 3), label: role.name, cells: { target: role.name, model: display(model.value ?? role.model), effort: display(effort.value ?? role.thinking), source: role.runner ? "External runner" : `${model.source}; ${effort.source}` } };
165
- }),
231
+ { value: "roles", label: "Agent roles…", cells: { target: "Agent roles…", model: `${roles.length} discovered`, effort: "", source: "Choose a role" } },
166
232
  ...(discovery.warning ? [{ value: "details", label: "Agent discovery details" }] : []),
167
233
  { value: "done", label: "Done" },
168
234
  ],
@@ -174,6 +240,17 @@ export async function defaultsFlow(pi: any, ctx: any, config: ModelsConfig): Pro
174
240
  scope = scope === "user" ? "project" : "user";
175
241
  } else if (index === 1) {
176
242
  await editPiDefault(pi, ctx, config, scope, user, project);
243
+ } else if (selected === "fallback") {
244
+ await editFallbackEffort(pi, ctx, scope);
245
+ } else if (selected === "roles") {
246
+ const roleState = {};
247
+ for (;;) {
248
+ const value = await tableSelect(ctx, { title: "BMS / Defaults / Agent roles", search: true, state: roleState,
249
+ rows: roles.map(role => ({ value: role.name, label: role.name, description: role.runner ? "External runner" : role.model ?? "inherit" })) });
250
+ const role = roles.find(role => role.name === value);
251
+ if (!role) break;
252
+ await editRole(pi, ctx, config, scope, discovery.projectRoot, role, readSettings(settingsPath(discovery.projectRoot, "user")), projectSettings(pi, ctx, discovery.projectRoot));
253
+ }
177
254
  } else if (selected === "details") {
178
255
  ctx.ui.notify(discovery.warning!, "warning");
179
256
  } else {
@@ -195,7 +272,7 @@ async function editRole(pi: any, ctx: any, config: ModelsConfig, scope: Scope, r
195
272
  let effort: string | false | undefined = role ? entry?.thinking : local.subagents?.defaultThinking;
196
273
  let modelChanged = false, effortChanged = false;
197
274
  await editForm(ctx, {
198
- title: `${title} defaults`,
275
+ title: `BMS / Defaults / ${title}`,
199
276
  hint: `Scope: ${scope}\n${path}\nProvider-specific and project overrides still take precedence. Removing effort restores agent-defined/fallback behavior, not parent inheritance.`,
200
277
  fields: [
201
278
  { key: "model", label: "Model", value: model ?? "remove override", edit: async () => {
@@ -10,7 +10,7 @@ export function modelEffortDraft(ctx: FlowCtx, key: string, reasoning: boolean,
10
10
  const previous = readSettings(settingsPath(ctx.cwd ?? process.cwd(), "user")).modelThinkingLevels?.[key];
11
11
  return { capability: { levels: [...levels], source }, preferred: isEffort(previous) ? previous : undefined };
12
12
  }
13
- export async function editModelEfforts(ctx: FlowCtx, key: string, reasoning: boolean, reported?: Effort[], initial?: EffortDraft): Promise<EffortDraft | undefined> {
13
+ export async function editModelEfforts(ctx: FlowCtx, key: string, reasoning: boolean, reported?: Effort[], initial?: EffortDraft, capabilitiesOnly = false): Promise<EffortDraft | undefined> {
14
14
  const draft = structuredClone(initial ?? modelEffortDraft(ctx, key, reasoning, reported));
15
15
  if (!reasoning) draft.capability = { levels: ["off"], source: "pi" };
16
16
  const saved = await editForm(ctx, {
@@ -26,12 +26,12 @@ export async function editModelEfforts(ctx: FlowCtx, key: string, reasoning: boo
26
26
  draft.capability = { levels, source: "manual" };
27
27
  return levels.join(", ");
28
28
  } },
29
- { key: "preferred", label: "Preferred effort", value: draft.preferred ?? "automatic", description: "Automatic removes the user per-model preference, not agent role defaults.", edit: async () => {
29
+ ...(!capabilitiesOnly ? [{ key: "preferred", label: "Preferred effort", value: draft.preferred ?? "automatic", description: "Automatic removes the user per-model preference, not agent role defaults.", edit: async () => {
30
30
  const selected = await tableSelect(ctx, { title: `Preferred effort for ${key}`, initialValue: draft.preferred ?? "automatic", rows: ["automatic", ...draft.capability.levels].map(value => ({ value, label: value })) });
31
31
  if (selected === undefined) return;
32
32
  draft.preferred = isEffort(selected) ? selected : undefined;
33
33
  return selected;
34
- } },
34
+ } }] : []),
35
35
  ],
36
36
  onSave: async () => {
37
37
  if (draft.preferred && !draft.capability.levels.includes(draft.preferred)) { ctx.ui.notify("Preferred effort must be available. Choose another effort or automatic.", "warning"); return false; }
@@ -62,7 +62,7 @@ async function modelForm(ctx: FlowCtx, provider: string, initial: ModelEntry, me
62
62
  const reasoning = value === "yes";
63
63
  // Do not change the visible field if its dependent effort draft is cancelled.
64
64
  const initialEfforts = reasoning === Boolean(draft.reasoning) ? efforts : modelEffortDraft(ctx, key, reasoning, meta.effortLevels);
65
- const next = await editModelEfforts(ctx, key, reasoning, meta.effortLevels, initialEfforts);
65
+ const next = await editModelEfforts(ctx, key, reasoning, meta.effortLevels, initialEfforts, true);
66
66
  if (!next) return;
67
67
  draft.reasoning = reasoning; efforts = next; effortsChanged = true; changes.add("reasoning"); return value;
68
68
  }),
@@ -71,8 +71,8 @@ async function modelForm(ctx: FlowCtx, provider: string, initial: ModelEntry, me
71
71
  if (value === undefined) return;
72
72
  draft.input = value.split("+"); changes.add("input"); return value;
73
73
  }),
74
- field("efforts", "Available / preferred efforts", effortLabel(efforts), async () => {
75
- const next = await editModelEfforts(ctx, key, Boolean(draft.reasoning), meta.effortLevels, efforts);
74
+ field("efforts", "Supported effort levels", effortLabel(efforts), async () => {
75
+ const next = await editModelEfforts(ctx, key, Boolean(draft.reasoning), meta.effortLevels, efforts, true);
76
76
  if (!next) return;
77
77
  efforts = next; effortsChanged = true; return effortLabel(efforts);
78
78
  }),
@@ -96,7 +96,7 @@ async function modelForm(ctx: FlowCtx, provider: string, initial: ModelEntry, me
96
96
  },
97
97
  });
98
98
  }
99
- function effortLabel(draft: EffortDraft): string { return `${draft.capability.levels.join(", ")}; preferred ${draft.preferred ?? "automatic"}`; }
99
+ function effortLabel(draft: EffortDraft): string { return draft.capability.levels.join(", "); }
100
100
 
101
101
  export async function addFlow(ctx: FlowCtx, onAdded?: (provider: string, modelId: string) => Promise<void>): Promise<void> {
102
102
  const config = loadConfig(ctx);
@@ -8,6 +8,7 @@ import { loadUsage, usageKey, type ModelsConfig } from "./store.ts";
8
8
  export type PickerAction =
9
9
  | { kind: "load"; provider: string; modelId: string }
10
10
  | { kind: "set-default"; provider: string; modelId: string }
11
+ | { kind: "effort"; provider: string; modelId: string }
11
12
  | { kind: "defaults" }
12
13
  | { kind: "add" }
13
14
  | { kind: "update"; provider?: string; modelId?: string }
@@ -26,6 +27,7 @@ export interface RankedModel {
26
27
  reasoning: boolean;
27
28
  current?: boolean;
28
29
  default?: boolean;
30
+ effort?: string;
29
31
  }
30
32
 
31
33
  /**
@@ -103,7 +105,7 @@ export function selectListTheme(theme: any): SelectListTheme {
103
105
  }
104
106
 
105
107
  // Keep the default shortcut ahead of secondary actions so narrow terminals show it.
106
- const HINT = "↑↓ move · enter load · ctrl+d set default · ctrl+o actions · esc close";
108
+ const HINT = "enter load · ctrl+d default · ctrl+a add · ctrl+o menu · esc close";
107
109
 
108
110
  export interface PickerOptions {
109
111
  placement?: Placement;
@@ -112,9 +114,11 @@ export interface PickerOptions {
112
114
  }
113
115
 
114
116
  const MODEL_COLUMNS: TableColumn[] = [
115
- { key: "model", label: "MODEL" }, { key: "provider", label: "PROVIDER", width: 14 },
116
- { key: "context", label: "CONTEXT", width: 7 }, { key: "uses", label: "USES", width: 5 },
117
- { key: "status", label: "STATUS", width: 17 },
117
+ { key: "model", label: "MODEL" }, { key: "provider", label: "PROVIDER", width: 10 },
118
+ { key: "effort", label: "EFFORT", width: 7 },
119
+ { key: "context", label: "CONTEXT", width: 7 }, { key: "uses", label: "USES", width: 4 },
120
+ { key: "default", label: "DEFAULT", width: 7 },
121
+ { key: "current", label: "CURRENT", width: 7 },
118
122
  ];
119
123
 
120
124
  export class ModelPicker implements Focusable {
@@ -147,8 +151,8 @@ export class ModelPicker implements Focusable {
147
151
  rows: rows.map(row => ({ value: toSelectItem(row).value, label: row.label,
148
152
  // IDs remain searchable but are not repeated under the display name.
149
153
  description: row.modelId,
150
- cells: { model: row.label, provider: row.provider, context: formatContext(row.contextWindow),
151
- uses: String(row.count), status: [row.current ? "current" : "", row.default ? "default" : ""].filter(Boolean).join(" · ") },
154
+ cells: { model: row.label, provider: row.provider, effort: row.effort ?? "auto", context: formatContext(row.contextWindow),
155
+ uses: String(row.count), default: row.default ? " ✓" : "", current: row.current ? " ✓" : "" },
152
156
  })), placement: options.placement,
153
157
  hint: assignment ? "↑↓ move · enter assign · esc back" : HINT,
154
158
  }, theme, () => { this.saveState(); changed(); }, value => {
@@ -166,30 +170,29 @@ export class ModelPicker implements Focusable {
166
170
  private action(kind: PickerAction["kind"]): void {
167
171
  this.saveState();
168
172
  const row = this.currentRow();
169
- if (kind === "load" || kind === "set-default") {
173
+ if (kind === "load" || kind === "set-default" || kind === "effort") {
170
174
  if (row) this.done({ kind, provider: row.provider, modelId: row.modelId });
171
175
  } else if (kind === "update" || kind === "delete") {
172
176
  this.done({ kind, provider: row?.provider, modelId: row?.modelId });
173
177
  } else this.done({ kind });
174
178
  }
175
179
  private openMenu(): void {
176
- const row = this.currentRow();
177
- const actions: { value: PickerAction["kind"]; label: string }[] = [
178
- ...(row ? [{ value: "load" as const, label: "Load selected model" },
179
- { value: "set-default" as const, label: "Set selected model as default" }] : []),
180
- { value: "add", label: "Add model" }, { value: "update", label: "Update model" },
181
- { value: "delete", label: "Delete model" }, { value: "defaults", label: "Model and agent defaults" },
182
- { value: "options", label: "Options" }, { value: "reset", label: "Reset usage ranking" },
180
+ const actions = [
181
+ { value: "models", label: "Models" },
182
+ { value: "add", label: "Add model…" },
183
+ { value: "defaults", label: "Defaults" },
184
+ { value: "options", label: "Options" },
185
+ { value: "back", label: "Back" },
183
186
  ];
184
- this.menu = new TableSelect({ title: "Models / Actions", rows: actions, search: false, initialValue: this.options.state?.menuValue,
187
+ this.menu = new TableSelect({ title: "BMS", rows: actions, search: false, placement: this.options.placement, initialValue: this.options.state?.menuValue,
185
188
  hint: "↑↓ move · enter choose · esc back" }, this.theme, this.changed, value => {
186
189
  if (this.options.state) {
187
- if (value === undefined) delete this.options.state.menuValue;
190
+ if (value === undefined || value === "models" || value === "back") delete this.options.state.menuValue;
188
191
  else this.options.state.menuValue = value;
189
192
  }
190
193
  this.menu = undefined;
191
194
  this.focused = this._focused;
192
- if (value !== undefined) this.action(value as PickerAction["kind"]);
195
+ if (value !== undefined && value !== "models" && value !== "back") this.action(value as PickerAction["kind"]);
193
196
  this.changed();
194
197
  });
195
198
  this.focused = this._focused;
@@ -199,7 +202,7 @@ export class ModelPicker implements Focusable {
199
202
  if (this.menu) { this.menu.handleInput(data); return; }
200
203
  const shortcuts: [string, PickerAction["kind"]][] = [
201
204
  ["ctrl+a", "add"], ["ctrl+u", "update"], ["ctrl+x", "delete"], ["ctrl+d", "set-default"],
202
- ["ctrl+g", "defaults"], ["ctrl+r", "reset"],
205
+ ["ctrl+g", "defaults"], ["ctrl+e", "effort"], ["ctrl+r", "reset"],
203
206
  ];
204
207
  if (matchesKey(data, "ctrl+o")) { if (!this.assignment) this.openMenu(); return; }
205
208
  for (const [key, kind] of shortcuts) {
@@ -207,6 +210,8 @@ export class ModelPicker implements Focusable {
207
210
  }
208
211
  this.table.handleInput(data);
209
212
  }
210
- render(width: number): string[] { return (this.menu ?? this.table).render(width); }
213
+ render(width: number): string[] {
214
+ return (this.menu ?? this.table).render(width);
215
+ }
211
216
  invalidate(): void { this.table.invalidate(); this.menu?.invalidate(); }
212
217
  }
@@ -23,6 +23,7 @@ export interface TableSelectOptions {
23
23
  columns?: TableColumn[];
24
24
  rows: TableRow[];
25
25
  hint?: string;
26
+ description?: string;
26
27
  search?: boolean;
27
28
  placement?: Placement;
28
29
  initialValue?: string;
@@ -155,16 +156,17 @@ export class TableSelect implements Component, Focusable {
155
156
  render(width: number): string[] {
156
157
  width = Math.max(1, width);
157
158
  const border = color(this.theme, "borderAccent", "─".repeat(width));
158
- const prompt = this.options.search === false ? [] : [border, ...this.input.render(width), border];
159
+ const prompt = [border, ...(this.options.search === false ? [color(this.theme, "dim", "↑↓ navigate · Enter choose")] : this.input.render(width)), border];
159
160
  const results = this.tableLines(width);
160
161
  const count = this.filtered.length === this.options.rows.length
161
162
  ? `${this.filtered.length} results` : `${this.filtered.length} of ${this.options.rows.length} results`;
162
163
  const summary = color(this.theme, "dim", truncateToWidth(count, width, ""));
163
164
  const shortcuts = shortcutLine(this.theme, this.options.hint ?? "↑↓ move · enter select · esc back", width);
165
+ const details = (this.options.description ?? "").split("\n").filter(Boolean).map(line => color(this.theme, "dim", truncateToWidth(line, width, "…")));
164
166
  return [color(this.theme, "accent", truncateToWidth(singleLine(this.options.title), width, "…")),
165
- ...(this.options.placement === "above" && prompt.length
166
- ? [shortcuts, summary, ...results, ...prompt]
167
- : [...prompt, ...results, summary, shortcuts])];
167
+ ...(this.options.placement === "above"
168
+ ? [shortcuts, ...details, summary, ...results, ...prompt]
169
+ : [...prompt, ...results, summary, ...details, shortcuts])];
168
170
  }
169
171
  invalidate(): void { this.input.invalidate(); }
170
172
  }
@@ -183,7 +185,7 @@ export interface ConfirmActionOptions { title: string; lines: string[]; confirmL
183
185
  export class ConfirmAction extends TableSelect {
184
186
  private lines: string[];
185
187
  constructor(options: ConfirmActionOptions, theme: any, changed: () => void, done: (confirmed: boolean) => void) {
186
- super({ title: options.title, search: false,
188
+ super({ title: options.title, search: false, placement: resultPlacement(),
187
189
  rows: [{ value: "cancel", label: "Cancel" }, { value: "confirm", label: options.confirmLabel }],
188
190
  }, theme, changed, value => done(value === "confirm"));
189
191
  this.lines = options.lines;
@@ -203,7 +205,7 @@ export interface FormField {
203
205
  edit: () => Promise<string | undefined>;
204
206
  }
205
207
  export interface EditFormOptions {
206
- title: string; fields: FormField[]; onSave: () => Promise<boolean | void>; hint?: string;
208
+ title: string; fields: FormField[]; onSave: () => Promise<boolean | void>; hint?: string; placement?: Placement;
207
209
  }
208
210
 
209
211
  /** Field callbacks edit the caller's draft. Only onSave may persist it. */
@@ -280,12 +282,21 @@ export class Form implements Component {
280
282
  return index + start === this.selected && typeof this.theme?.bg === "function"
281
283
  ? this.theme.bg("selectedBg", color(this.theme, "accent", text)) : text;
282
284
  });
283
- return [color(this.theme, "accent", this.options.title + (this.dirty ? " *" : "")),
284
- color(this.theme, "muted", ` ${fitCell("FIELD", labelWidth)} VALUE`), ...rows,
285
- ...(this.fields[this.selected]?.description ? [color(this.theme, "dim", this.fields[this.selected]!.description!)] : []),
285
+ // Reserve help, feedback, and visible-row slots so selection never moves the prompt.
286
+ const rowCount = Math.min(6, formRows.length);
287
+ while (rows.length < rowCount) rows.push("");
288
+ const help = this.fields.some(field => field.description)
289
+ ? [color(this.theme, "dim", this.fields[this.selected]?.description ?? "")] : [];
290
+ const context = [...help,
286
291
  ...(this.options.hint ? this.options.hint.split("\n").map(line => color(this.theme, "dim", line)) : []),
287
- ...(this.busy || this.error ? [this.busy ? "Working…" : color(this.theme, "error", this.error)] : []),
288
- shortcutLine(this.theme, this.discard ? "↑↓ move · enter choose · esc back" : "↑↓ move · enter choose · ctrl+s save · esc back", width)]
292
+ this.busy ? "Working…" : this.error ? color(this.theme, "error", this.error) : ""];
293
+ const content = [color(this.theme, "muted", ` ${fitCell("FIELD", labelWidth)} VALUE`), ...rows];
294
+ const shortcuts = shortcutLine(this.theme, this.discard ? "↑↓ move · enter choose · esc back" : "↑↓ move · enter choose · ctrl+s save · esc back", width);
295
+ const border = color(this.theme, "borderAccent", "─".repeat(Math.max(1, width)));
296
+ const prompt = [border, color(this.theme, "dim", "Enter edit · Ctrl+S save"), border];
297
+ return [color(this.theme, "accent", this.options.title + (this.dirty ? " *" : "")),
298
+ ...((this.options.placement ?? resultPlacement()) === "above"
299
+ ? [shortcuts, ...context, ...content, ...prompt] : [...prompt, ...content, ...context, shortcuts])]
289
300
  .map(line => truncateToWidth(line, Math.max(1, width), "…"));
290
301
  }
291
302
  invalidate(): void {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-model-select",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
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": [
@@ -26,8 +26,7 @@
26
26
  "!extensions/**/*.test.ts",
27
27
  "README.md",
28
28
  "CHANGELOG.md",
29
- "docs/configuration.md",
30
- "docs/development.md",
29
+ "docs/public/configuration.md",
31
30
  "docs/demo/showcase.gif",
32
31
  "LICENSE"
33
32
  ],
@@ -1,104 +0,0 @@
1
- # Development and testing
2
-
3
- Run these commands from the repository checkout:
4
-
5
- ```bash
6
- npm ci
7
- npm run gate
8
- npm run test:smoke # optional: installed Pi, Python 3 and a POSIX PTY required
9
- pi --no-extensions -e ./extensions/bms.ts # temporary load; no install or publish
10
- ```
11
-
12
- ## Temporary local testing
13
-
14
- Use a fresh terminal in this checkout. The command above disables installed
15
- extensions to avoid loading two copies of BMS. It still uses your real Pi settings,
16
- so default changes persist. For an isolated test, use a scratch agent directory
17
- and project directory instead:
18
-
19
- ```bash
20
- REPO="$PWD"
21
- SANDBOX="$(mktemp -d -t bms-local)"
22
- mkdir -p "$SANDBOX/agent" "$SANDBOX/project/.pi"
23
- printf '{}\n' > "$SANDBOX/project/.pi/settings.json"
24
- # Optional: copy your model definitions. Review literal credentials in this file.
25
- cp "${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/models.json" "$SANDBOX/agent/models.json"
26
- cd "$SANDBOX/project"
27
- PI_CODING_AGENT_DIR="$SANDBOX/agent" pi --no-extensions -e "$REPO/extensions/bms.ts"
28
- ```
29
-
30
- Provider API-key environment variables remain available. The isolated directory
31
- has no login credentials; use `/login` there if needed. Do not commit credentials
32
- or scratch settings. To test agent roles, load your installed pi-subagents entry
33
- explicitly with another `-e` and make the package resolvable in the scratch agent's
34
- `npm/node_modules`, or use your real agent directory with backups. For the standard
35
- Pi npm install layout with pi-subagents 0.82.1, before launching:
36
-
37
- ```bash
38
- REAL_AGENT="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
39
- ln -s "$REAL_AGENT/npm" "$SANDBOX/agent/npm"
40
- PI_CODING_AGENT_DIR="$SANDBOX/agent" pi --no-extensions \
41
- -e "$REPO/extensions/bms.ts" -e "$REAL_AGENT/npm/node_modules/pi-subagents/bundle/index.js"
42
- ```
43
-
44
- This symlinks installed package code, not your settings or auth files. Do not run
45
- package installation/update commands in this sandbox, since they would modify the
46
- shared package directory.
47
-
48
- The automated `npm run test:smoke` uses an unreachable dummy endpoint and
49
- throwaway settings. It checks command registration, the real TUI default-save
50
- flow, the defaults shortcut, and restoration in a fresh Pi process. No model
51
- prompts are sent. The regular test suite uses mocked provider metadata and UI.
52
-
53
- Manual checks:
54
-
55
- 1. `/bms`: filter and use Enter. Only the current session should change.
56
- 2. Reopen `/bms`, press Ctrl+D, choose effort and confirm. Check both markers,
57
- the footer effort, and the scratch `agent/settings.json`.
58
- 3. Cancel the effort dialog and confirmation. Neither should change defaults.
59
- 4. `/bms-defaults`: select a Pi model, then select the same model again to change
60
- only its default effort. Test user and trusted-project scope.
61
- 5. Configure a subagent fallback and a role model/effort. Confirm the parent
62
- session stays unchanged. Test explicit model inheritance and removal of each
63
- override, then `/reload` and inspect `/subagents-models reviewer`.
64
- 6. Add/update a model. Check provider-reported effort lists, the unverified Pi
65
- fallback, manual levels, and the preferred effort. Try removing a level that
66
- a directly pinned default uses; BMS should block the conflicting edit.
67
- 7. Exit and start a fresh Pi session with the same scratch paths. Confirm startup
68
- model and effort. Do not use `--continue`, `--resume`, or CLI model/effort overrides.
69
- 8. Test a narrow terminal and verify the list, wrapped hints, and search still work.
70
-
71
- Quit Pi before deleting the sandbox with `rm -rf "$SANDBOX"`, then return to the
72
- checkout with `cd "$REPO"`. The test commands do not publish or install BMS.
73
-
74
- ## Publishing
75
-
76
- Publish from `main` after the feature PR is reviewed and merged. Do not publish from the unmerged feature branch.
77
-
78
- For the current `0.2.0` release:
79
-
80
- ```bash
81
- git switch main
82
- git pull --ff-only origin main
83
- npm ci
84
- npm run gate
85
- npm run test:smoke
86
- npm pack --dry-run
87
- ```
88
-
89
- Check that the working tree is clean, both package manifests say `0.2.0`, and the package contains `README.md`, `docs/configuration.md`, and `docs/development.md`. Tests and the internal PR draft must not be included. The README links point to `main`, so merge the documentation before publishing.
90
-
91
- Then publish to the public npm registry:
92
-
93
- ```bash
94
- make publish
95
- ```
96
-
97
- For later releases, update both manifests with `npm version <version> --no-git-tag-version`, and include that change in a reviewed PR before publishing.
98
-
99
- The target runs the full package gate, checks npm authentication, opens npm's
100
- browser login when needed, skips the upload if that exact version already
101
- exists, publishes the public package, and waits for npm to expose the
102
- new version. npm can take several minutes to process a package. If verification
103
- times out, rerun `make verify-publish`; do not publish the same version again.
104
-