pi-better-model-select 0.2.0 → 0.3.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,55 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ ### Added
6
+
7
+ - `/bms-options` for direct access to result placement, write mode, and provider visibility.
8
+ - Above-prompt result placement, with the best match nearest search. Below-prompt placement remains the default.
9
+ - Ctrl+O Actions menu with access to model management, defaults, options, and usage reset.
10
+ - Visible **Save configuration** and **Back** actions in forms, alongside Ctrl+S and Esc.
11
+
12
+ ### Changed
13
+
14
+ - Replaced the model list with aligned MODEL, PROVIDER, CONTEXT, USES, and STATUS columns, a full-row selection highlight, and up to six visible results.
15
+ - Moved search into a prompt-style field in Pi's editor area. Shortcut hints and the result count follow result placement.
16
+ - Replaced model-edit questionnaires with draft forms. Changes are saved explicitly; leaving a modified form asks whether to discard them.
17
+ - Organized Pi and subagent defaults into a table with model, effort, and source information.
18
+ - Preserved search and selection across submenus. Returning from an Actions submenu restores the selected action.
19
+ - Kept non-search menus top-to-bottom regardless of result placement.
20
+ - Styled shortcut keys separately from action descriptions and omitted whole hints at narrow widths.
21
+ - Made destructive confirmations cancel-first and explicit about write scope.
22
+
23
+ ### Fixed
24
+
25
+ - Restored the same form draft and selection after closing a child dialog, without losing it when Pi restores its editor.
26
+
27
+ ## 0.2.0
28
+
29
+ ### Added
30
+
31
+ - Default model and thinking effort configuration for Pi, the subagent fallback, and individual agent roles.
32
+ - `/bms-defaults`, Ctrl+D for the Pi default, and Ctrl+G for the defaults menu.
33
+ - User/project scope selection, effective-value sources, and warnings about higher-priority overrides.
34
+ - Provider-reported effort capability hints, editable supported levels, and per-model preferred effort.
35
+ - Active-model and saved-default markers in the picker.
36
+
37
+ ### Fixed
38
+
39
+ - Discovery of pi-subagents roles with the newer bundled entry-point layout, checked against pi-subagents 0.82.1 after the original 0.79.0 integration.
40
+
41
+ ## 0.1.4
42
+
43
+ - Added BMS options and provider filters.
44
+
45
+ ## 0.1.3
46
+
47
+ - Renamed the package to `pi-better-model-select`.
48
+
49
+ ## 0.1.2
50
+
51
+ - Persisted deletion of inherited models in the personal overlay.
52
+
53
+ ## 0.1.1
54
+
55
+ - Added a personal model overlay so local edits can remain separate from Pi's model configuration.
package/README.md CHANGED
@@ -10,12 +10,21 @@ pi install npm:pi-better-model-select
10
10
 
11
11
  Run `/bms`, type to filter by name, model ID, or provider, then press Enter to switch models for the current session.
12
12
 
13
+ ## Compact TUI
14
+
15
+ ![BMS model search, forms, defaults, and result placement](docs/demo/showcase.gif)
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.
18
+
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
+
13
21
  ## Commands and shortcuts
14
22
 
15
23
  | Command | Action |
16
24
  | --- | --- |
17
25
  | `/bms` | Open the model picker |
18
26
  | `/bms-add` | Add a model |
27
+ | `/bms-options` | Open result placement, write mode, and provider options |
19
28
  | `/bms-defaults` | Configure Pi and subagent defaults |
20
29
 
21
30
  Inside `/bms`:
@@ -27,15 +36,15 @@ Inside `/bms`:
27
36
  | Ctrl+D | Choose effort, save as the Pi default, and use it now |
28
37
  | Ctrl+G | Open the defaults menu |
29
38
  | Ctrl+A / Ctrl+U / Ctrl+X | Add / update / delete a model |
30
- | Ctrl+O | Set write mode and provider filters |
39
+ | Ctrl+O | Open actions, including options and defaults |
31
40
  | Ctrl+R | Reset usage ranking |
32
41
  | Esc | Close |
33
42
 
34
- The picker marks the active model with `[current]` and the saved startup model with `[default]`. Only Enter selections count toward usage ranking.
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.
35
44
 
36
45
  ## Default models and effort
37
46
 
38
- New in BMS `0.2.0`: save a model and thinking effort for Pi, a subagent fallback, or an individual agent role.
47
+ Save a model and thinking effort for Pi, a subagent fallback, or an individual agent role.
39
48
 
40
49
  - Use Ctrl+D in `/bms` for the main Pi default.
41
50
  - Use `/bms-defaults` for all defaults. The menu starts in user scope; select the scope row to switch to a trusted project's settings.
@@ -55,7 +64,7 @@ pi install npm:pi-subagents
55
64
 
56
65
  BMS discovers its agent roles and edits their saved model and effort defaults. It does not install `pi-subagents` automatically. External runner roles are read-only because their model settings belong to the runner.
57
66
 
58
- Discovery has now been checked with **pi-subagents `0.82.1`**, updated from the original `0.79.0` test. BMS handles the newer bundled entry-point layout. The adapter still uses internal modules, so future package changes may require an update. If discovery fails, **Agent discovery details** shows the error. Pi defaults work without this integration.
67
+ If discovery fails, **Agent discovery details** shows the error. Pi defaults work without this integration. See the configuration reference for compatibility details.
59
68
 
60
69
  ### Supported effort levels
61
70
 
@@ -69,7 +78,7 @@ Add/update also lets you edit supported levels and set a per-model preferred eff
69
78
 
70
79
  ## Where changes are saved
71
80
 
72
- Ctrl+O selects the model write mode:
81
+ Open Ctrl+O → Options to select the model write mode:
73
82
 
74
83
  | Mode | Behavior |
75
84
  | --- | --- |
@@ -82,8 +91,9 @@ Provider filters hide entries only in `/bms`, not Pi's `/model` picker. Default-
82
91
 
83
92
  ## Further documentation
84
93
 
85
- - [Configuration reference](https://g.han.zone/msenebald/pi-better-model-select/src/branch/main/docs/configuration.md): file paths, precedence, overlays, metadata, and integration limits.
86
- - [Development and testing](https://g.han.zone/msenebald/pi-better-model-select/src/branch/main/docs/development.md): local loading, isolated tests, checks, and publishing.
94
+ - [Changelog](CHANGELOG.md): changes by version.
95
+ - [Configuration reference](docs/configuration.md): file paths, precedence, overlays, metadata, and integration limits.
96
+ - [Development and testing](docs/development.md): local loading, isolated tests, checks, and publishing.
87
97
 
88
98
  ## License
89
99
 
@@ -10,7 +10,7 @@ See the [README](../README.md) for installation and shortcuts.
10
10
  | --- | --- |
11
11
  | `<agent-dir>/models.json` | Pi model definitions |
12
12
  | `<agent-dir>/models.bms.json` | BMS model overlay |
13
- | `<agent-dir>/bms-settings.json` | Write mode and provider filters |
13
+ | `<agent-dir>/bms-settings.json` | Write mode, result placement, and provider filters |
14
14
  | `<agent-dir>/bms-usage.json` | Selection counts and recency |
15
15
  | `<agent-dir>/bms-model-metadata.json` | Effort capability hints and their source |
16
16
  | `<agent-dir>/settings.json` | User defaults and per-model effort preferences |
@@ -18,7 +18,7 @@ See the [README](../README.md) for installation and shortcuts.
18
18
 
19
19
  ## Model write modes
20
20
 
21
- Choose the write mode through Ctrl+O in `/bms`.
21
+ Choose the write mode through Ctrl+O → Options in `/bms`, then Ctrl+S to save.
22
22
 
23
23
  - Overlay mode, the default, leaves `models.json` unchanged and writes edits to `models.bms.json`.
24
24
  - Direct mode writes add, update, and delete operations to `models.json`.
@@ -35,6 +35,12 @@ Remove that entry from `models.bms.json` to restore the inherited model. Model f
35
35
 
36
36
  Provider filters affect only `/bms`. They do not change Pi's `/model` picker or model definitions. Default-assignment menus ignore the filters and also include authenticated Pi registry models.
37
37
 
38
+ ## Picker layout
39
+
40
+ `bms-settings.json` accepts `resultPlacement: "below"` or `"above"`. Missing or invalid values use `"below"`, matching Pi command completion. In above mode the best match is the bottom row, immediately above search; arrow keys follow physical screen direction. Searchable assignment menus use the same preference. Forms retain their normal top-to-bottom field order.
41
+
42
+ The search field is part of a temporary custom interaction in Pi's editor area. BMS does not replace the normal editor factory. Enter loads the selected model; Esc returns to the editor. Up to six results are shown at a time, with scrolling for the rest.
43
+
38
44
  ## Defaults and precedence
39
45
 
40
46
  Default settings are independent of the model write mode.
package/extensions/bms.ts CHANGED
@@ -8,6 +8,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
8
  import { defaultsFlow, piPreference, setMainDefault } from "./lib/defaults.ts";
9
9
  import { readSettings, settingsPath } from "./lib/defaults-store.ts";
10
10
  import { pickerDialog } from "./lib/picker-dialog.ts";
11
+ import { confirmAction, editForm, tableSelect } from "./lib/tui.ts";
11
12
  import { addFlow, deleteFlow, updateFlow, type FlowCtx } from "./lib/flows.ts";
12
13
  import { rankModels } from "./lib/picker.ts";
13
14
  import {
@@ -78,51 +79,50 @@ function syncLocalConfig(pi: ExtensionAPI, ctx: any): ModelsConfig | null {
78
79
  return effective;
79
80
  }
80
81
 
81
- /** Re-read model configuration from disk and switch the session to the given model. */
82
+ /** Stage option changes until the user explicitly saves. */
82
83
  async function optionsFlow(ctx: any, config: ModelsConfig): Promise<void> {
83
- for (;;) {
84
- const settings = loadSettings();
85
- const writeLabel =
86
- settings.writeMode === "overlay" ? "BMS overlay (models.bms.json)" : "Pi config (models.json)";
87
- const hidden = settings.disabledProviders.length;
88
- const pick = await ctx.ui.select("BMS options", [
89
- `Write mode: ${writeLabel}`,
90
- `Provider filters: ${hidden} hidden`,
91
- "Done",
92
- ]);
93
- if (!pick || pick === "Done") return;
94
-
95
- if (pick.startsWith("Write mode:")) {
96
- const mode = await ctx.ui.select("Where should BMS write model changes?", [
97
- "BMS overlay (models.bms.json)",
98
- "Pi config (models.json)",
99
- ]);
100
- if (!mode) continue;
101
- settings.writeMode = mode.startsWith("BMS overlay") ? "overlay" : "direct";
102
- saveSettings(settings);
103
- ctx.ui.notify(`BMS write mode: ${settings.writeMode}.`, "info");
104
- continue;
105
- }
106
-
107
- const providers = Object.keys(config.providers ?? {}).sort();
108
- for (;;) {
109
- const current = loadSettings();
110
- const options = providers.map((provider) =>
111
- `${current.disabledProviders.includes(provider) ? "[ ]" : "[x]"} ${provider}`,
112
- );
113
- const choice = await ctx.ui.select(
114
- "Providers shown in /bms (select to toggle, ESC when done)",
115
- options,
116
- );
117
- if (!choice) break;
118
- const provider = choice.slice(4);
119
- const disabled = new Set(current.disabledProviders);
120
- if (disabled.has(provider)) disabled.delete(provider);
121
- else disabled.add(provider);
122
- current.disabledProviders = [...disabled].sort();
123
- saveSettings(current);
124
- }
125
- }
84
+ const settings = loadSettings();
85
+ const providers = Object.keys(config.providers ?? {}).sort();
86
+ const writeLabel = () => settings.writeMode === "overlay" ? "BMS overlay (models.bms.json)" : "Pi config (models.json)";
87
+ await editForm(ctx, {
88
+ title: "Models / Options",
89
+ fields: [
90
+ { key: "placement", label: "Result placement", value: settings.resultPlacement === "above" ? "Above prompt" : "Below prompt",
91
+ description: "The best match stays nearest the search line. Arrow keys follow the displayed direction.",
92
+ edit: async () => {
93
+ const value = await tableSelect(ctx, { title: "Models / Options / Result placement", initialValue: settings.resultPlacement,
94
+ rows: [{ value: "below", label: "Below prompt", description: "Pi-style results. Down moves into the list." }, { value: "above", label: "Above prompt", description: "Results grow upward. Up moves into the list." }] });
95
+ if (!value) return;
96
+ settings.resultPlacement = value as "above" | "below";
97
+ return value === "above" ? "Above prompt" : "Below prompt";
98
+ } },
99
+ { key: "write", label: "Write mode", value: writeLabel(),
100
+ description: "Overlay keeps BMS edits separate from models.json. Direct mode edits Pi's model configuration.",
101
+ edit: async () => {
102
+ const value = await tableSelect(ctx, { title: "Models / Options / Write mode", initialValue: settings.writeMode,
103
+ rows: [{ value: "overlay", label: "BMS overlay", description: "models.bms.json" }, { value: "direct", label: "Pi config", description: "models.json" }] });
104
+ if (!value) return;
105
+ settings.writeMode = value as "overlay" | "direct";
106
+ return writeLabel();
107
+ } },
108
+ { key: "providers", label: "Providers", value: `${providers.filter(p => !settings.disabledProviders.includes(p)).length} enabled`,
109
+ description: "Choose a provider to toggle visibility. Esc returns to options; Ctrl+S saves the complete draft.",
110
+ edit: async () => {
111
+ let selected: string | undefined;
112
+ for (;;) {
113
+ selected = await tableSelect(ctx, { title: "Models / Options / Providers", initialValue: selected, search: true, placement: settings.resultPlacement,
114
+ rows: providers.map(provider => ({ value: provider, label: `${settings.disabledProviders.includes(provider) ? "[ ]" : "[x]"} ${provider}` })),
115
+ hint: "↑↓ move Enter toggle Esc back" });
116
+ if (!selected) break;
117
+ const disabled = new Set(settings.disabledProviders);
118
+ if (disabled.has(selected)) disabled.delete(selected); else disabled.add(selected);
119
+ settings.disabledProviders = [...disabled].sort();
120
+ }
121
+ return `${providers.filter(p => !settings.disabledProviders.includes(p)).length} enabled`;
122
+ } },
123
+ ],
124
+ onSave: async () => { saveSettings(settings); ctx.ui.notify("BMS options saved.", "info"); return true; },
125
+ });
126
126
  }
127
127
 
128
128
  async function loadModel(
@@ -173,6 +173,16 @@ export default function (pi: ExtensionAPI) {
173
173
  const addModel = async (ctx: any) => addFlow(ctx, async (provider, modelId) => {
174
174
  if (syncLocalConfig(pi, ctx)) await report(ctx, () => setMainDefault(pi, ctx, provider, modelId));
175
175
  });
176
+ pi.registerCommand("bms-options", {
177
+ description: "Configure BMS result placement, write mode, and provider visibility",
178
+ handler: async (_args, ctx: any) => {
179
+ if (ctx.mode !== "tui") { ctx.ui.notify("/bms-options needs an interactive terminal.", "warning"); return; }
180
+ await report(ctx, async () => {
181
+ const config = syncLocalConfig(pi, ctx);
182
+ if (config) await optionsFlow(ctx, config);
183
+ });
184
+ },
185
+ });
176
186
  pi.registerCommand("bms-defaults", {
177
187
  description: "Configure Pi and subagent default models and effort levels",
178
188
  handler: async (_args, ctx: any) => {
@@ -192,7 +202,8 @@ export default function (pi: ExtensionAPI) {
192
202
  return;
193
203
  }
194
204
 
195
- // Loop so the picker reappears after each add / update / delete.
205
+ // Preserve search and selection while opening submenus.
206
+ const pickerState: { query?: string; selectedValue?: string } = {};
196
207
  for (;;) {
197
208
  const config = syncLocalConfig(pi, ctx);
198
209
  if (!config) return;
@@ -230,7 +241,7 @@ export default function (pi: ExtensionAPI) {
230
241
  row.default = preference.provider === row.provider && preference.model === row.modelId;
231
242
  }
232
243
  } catch (error) { ctx.ui.notify(`Cannot read defaults: ${error}`, "warning"); }
233
- const action = await pickerDialog(ctx, rows);
244
+ const action = await pickerDialog(ctx, rows, "Models", false, { placement: settings.resultPlacement, state: pickerState });
234
245
 
235
246
  switch (action?.kind) {
236
247
  case "load":
@@ -265,10 +276,11 @@ export default function (pi: ExtensionAPI) {
265
276
  await optionsFlow(ctx, config);
266
277
  break;
267
278
  case "reset": {
268
- const ok = await ctx.ui.confirm(
269
- "Reset usage statistics?",
270
- "Model ranking falls back to alphabetical order.",
271
- );
279
+ const ok = await confirmAction(ctx, {
280
+ title: "Models / Reset usage",
281
+ lines: ["Clear all BMS model selection counts and last-used times.", "Ranking returns to alphabetical order. Models and defaults are unchanged."],
282
+ confirmLabel: "Reset usage",
283
+ });
272
284
  if (ok) {
273
285
  saveUsage({ version: 1, counts: {}, lastUsed: {} });
274
286
  ctx.ui.notify("Usage statistics cleared.", "info");
@@ -1,6 +1,6 @@
1
1
  import { discoverRoles, type AgentRole } from "./agents.ts";
2
2
  import { capabilities, isEffort, patchSettings, readSettings, roleField, setPiDefault, setRoleField, settingsPath, type Effort, type JsonObject, type Scope } from "./defaults-store.ts";
3
- import { pickerDialog } from "./picker-dialog.ts";
3
+ import { tableSelect, editForm, confirmAction } from "./tui.ts";
4
4
  import { rankModelsForDefaults } from "./defaults-models.ts";
5
5
  import type { ModelsConfig } from "./store.ts";
6
6
 
@@ -8,7 +8,10 @@ export async function chooseEffort(ctx: any, key: string, model?: any, initial?:
8
8
  const capability = capabilities(key, model);
9
9
  const levels = [...capability.levels];
10
10
  if (isEffort(initial) && levels.includes(initial)) levels.splice(levels.indexOf(initial), 1), levels.unshift(initial);
11
- return ctx.ui.select(`Effort for ${key}\n${capability.source}\nAutomatic removes this scope's preference. Pi/provider may clamp unsupported levels.`, ["automatic", ...levels]);
11
+ const title = `Effort for ${key}\n${capability.source}\nAutomatic removes this scope's preference. Pi/provider may clamp unsupported levels.`;
12
+ // Keep the public helper usable by RPC and legacy headless select mocks.
13
+ if (typeof ctx.ui.custom !== "function") return ctx.ui.select(title, ["automatic", ...levels]);
14
+ return await tableSelect(ctx, { title, initialValue: typeof initial === "string" ? initial : "automatic", rows: ["automatic", ...levels].map(value => ({ value, label: value })) }) as Effort | "automatic" | undefined;
12
15
  }
13
16
  export function piPreference(user: JsonObject, project: JsonObject): { provider?: string; model?: string; effort?: Effort; source: string } {
14
17
  const provider = project.defaultProvider ?? user.defaultProvider;
@@ -21,43 +24,52 @@ function trusted(pi: any, ctx: any): boolean {
21
24
  return typeof ctx.isProjectTrusted === "function" ? ctx.isProjectTrusted() : typeof pi.isProjectTrusted === "function" && pi.isProjectTrusted();
22
25
  }
23
26
  function projectSettings(pi: any, ctx: any, cwd: string): JsonObject { return trusted(pi, ctx) ? readSettings(settingsPath(cwd, "project")) : {}; }
27
+ function confirmDefault(ctx: any, title: string, message: string, confirmLabel: string): Promise<boolean> {
28
+ return typeof ctx.ui.custom === "function"
29
+ ? confirmAction(ctx, { title, lines: message.split("\n"), confirmLabel })
30
+ : ctx.ui.confirm(title, message);
31
+ }
24
32
 
25
33
  /** Switch first; never replace saved defaults when authentication/model selection fails. */
26
34
  export async function setMainDefault(pi: any, ctx: any, provider: string, modelId: string, scope: Scope = "user"): Promise<void> {
35
+ await applyMainDefault(pi, ctx, provider, modelId, scope);
36
+ }
37
+ async function applyMainDefault(pi: any, ctx: any, provider: string, modelId: string, scope: Scope, selectedEffort?: Effort | "automatic"): Promise<boolean> {
27
38
  if (scope === "project" && !trusted(pi, ctx)) throw new Error("Project is not trusted");
28
39
  await ctx.modelRegistry.refresh();
29
40
  const model = ctx.modelRegistry.find(provider, modelId);
30
41
  if (!model) throw new Error(`Model ${provider}/${modelId} is not in the active registry`);
31
42
  const user = readSettings(settingsPath(ctx.cwd, "user"));
32
43
  const project = projectSettings(pi, ctx, ctx.cwd);
33
- const choice = await chooseEffort(ctx, `${provider}/${modelId}`, model, piPreference(user, project).effort);
34
- if (choice === undefined) return;
44
+ const choice = selectedEffort ?? await chooseEffort(ctx, `${provider}/${modelId}`, model, piPreference(user, project).effort);
45
+ if (choice === undefined) return false;
35
46
  const effort = choice === "automatic" ? undefined : choice;
36
47
  const preview = structuredClone(scope === "user" ? user : project);
37
48
  setPiDefault(preview, provider, modelId, effort);
38
49
  const effective = piPreference(scope === "user" ? preview : user, scope === "project" ? preview : project);
39
50
  const shadowed = effective.provider !== provider || effective.model !== modelId || (effort !== undefined && effective.effort !== effort);
40
- if (shadowed && !(await ctx.ui.confirm("Higher-priority settings", "Project or per-model settings override this preference at startup. Save and use your selection in this session anyway?"))) return;
41
- if (!(await ctx.ui.confirm("Save and use Pi default?", `${provider}/${modelId}, effort ${choice}\nScope: ${scope}\n${settingsPath(ctx.cwd, scope)}`))) return;
51
+ if (shadowed && !(await confirmDefault(ctx, "Higher-priority settings", "Project or per-model settings override this preference at startup. Save and use your selection in this session anyway?", "Save and use anyway"))) return false;
52
+ if (selectedEffort === undefined && !(await confirmDefault(ctx, "Save and use Pi default?", `${provider}/${modelId}, effort ${choice}\nScope: ${scope}\n${settingsPath(ctx.cwd, scope)}`, "Save and use"))) return false;
42
53
  // Parse before changing session state. A malformed file must never be overwritten.
43
54
  readSettings(settingsPath(ctx.cwd, scope));
44
55
  const previousModel = ctx.model;
45
56
  const previousEffort = pi.getThinkingLevel();
46
57
  if (!(await pi.setModel(model))) {
47
58
  ctx.ui.notify("Could not switch model. Defaults were not changed.", "warning");
48
- return;
59
+ return false;
49
60
  }
50
61
  const requested = effort ?? effective.effort ?? "medium";
51
62
  try {
52
63
  pi.setThinkingLevel(requested);
53
64
  const applied = pi.getThinkingLevel();
54
- if (effort !== undefined && applied !== effort && !(await ctx.ui.confirm("Pi adjusted the effort", `Requested ${effort}; Pi uses ${applied}. Save ${applied} instead?`))) {
65
+ if (effort !== undefined && applied !== effort && !(await confirmDefault(ctx, "Pi adjusted the effort", `Requested ${effort}; Pi uses ${applied}. Save ${applied} instead?`, `Save ${applied}`))) {
55
66
  if (previousModel) await pi.setModel(previousModel);
56
67
  pi.setThinkingLevel(previousEffort);
57
- return;
68
+ return false;
58
69
  }
59
70
  patchSettings(settingsPath(ctx.cwd, scope), data => setPiDefault(data, provider, modelId, effort === undefined ? undefined : applied));
60
71
  ctx.ui.notify(`Using ${provider}/${modelId}, effort ${applied}. Saved ${scope} Pi default.${shadowed ? " Higher-priority startup settings still apply." : ""}`, "info");
72
+ return true;
61
73
  } catch (error) {
62
74
  if (previousModel) await pi.setModel(previousModel);
63
75
  pi.setThinkingLevel(previousEffort);
@@ -73,11 +85,53 @@ function display(value: unknown): string {
73
85
  async function assignModel(ctx: any, config: ModelsConfig, title: string): Promise<{ provider: string; modelId: string } | undefined> {
74
86
  const rows = rankModelsForDefaults(config, ctx);
75
87
  if (!rows.length) { ctx.ui.notify("No models available to assign.", "warning"); return; }
76
- const action = await pickerDialog(ctx, rows, title, true);
77
- return action?.kind === "load" ? action : undefined;
88
+ const value = await tableSelect(ctx, { title, search: true, columns: [{ key: "model", label: "MODEL" }, { key: "provider", label: "PROVIDER" }], rows: rows.map((row, index) => ({ value: String(index), label: row.label, cells: { model: row.modelId, provider: row.provider } })) });
89
+ const row = value === undefined ? undefined : rows[Number(value)];
90
+ return row ? { provider: row.provider, modelId: row.modelId } : undefined;
91
+ }
92
+ async function editPiDefault(pi: any, ctx: any, config: ModelsConfig, scope: Scope, user: JsonObject, project: JsonObject): Promise<void> {
93
+ const current = piPreference(user, project);
94
+ let provider = current.provider, modelId = current.model;
95
+ let effort: Effort | "automatic" = current.effort ?? "automatic";
96
+ let reset = false;
97
+ const path = settingsPath(ctx.cwd, scope);
98
+ await editForm(ctx, {
99
+ title: "Pi session default",
100
+ hint: `Scope: ${scope}\n${path}\nSave switches the session after validating the model. Higher-priority settings still apply at startup.`,
101
+ fields: [
102
+ { key: "model", label: "Model", value: `${provider ?? "auto"}/${modelId ?? "auto"}`, edit: async () => {
103
+ 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." }] });
104
+ if (action === undefined) return;
105
+ if (action === "reset") {
106
+ 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;
107
+ reset = true; return "remove scope defaults";
108
+ }
109
+ const selected = await assignModel(ctx, config, "Choose Pi default model");
110
+ if (!selected) return;
111
+ provider = selected.provider; modelId = selected.modelId; reset = false;
112
+ return `${provider}/${modelId}`;
113
+ } },
114
+ { key: "effort", label: "Effort", value: effort, edit: async () => {
115
+ if (!provider || !modelId || reset) { ctx.ui.notify("Choose a model first.", "warning"); return; }
116
+ const selected = await chooseEffort(ctx, `${provider}/${modelId}`, ctx.modelRegistry.find(provider, modelId), effort);
117
+ if (selected === undefined) return;
118
+ effort = selected; return selected;
119
+ } },
120
+ ],
121
+ onSave: async () => {
122
+ if (scope === "project" && !trusted(pi, ctx)) throw new Error("Project is not trusted");
123
+ if (reset) {
124
+ patchSettings(path, data => { delete data.defaultProvider; delete data.defaultModel; delete data.defaultThinkingLevel; });
125
+ return true;
126
+ }
127
+ if (!provider || !modelId) { ctx.ui.notify("Choose a model first.", "warning"); return false; }
128
+ return applyMainDefault(pi, ctx, provider, modelId, scope, effort);
129
+ },
130
+ });
78
131
  }
79
132
  export async function defaultsFlow(pi: any, ctx: any, config: ModelsConfig): Promise<void> {
80
133
  let scope: Scope = "user";
134
+ const state = {};
81
135
  for (;;) {
82
136
  const discovery = await discoverRoles(ctx.cwd, pi, ctx.model?.provider, trusted(pi, ctx));
83
137
  const user = readSettings(settingsPath(ctx.cwd, "user"));
@@ -88,28 +142,33 @@ export async function defaultsFlow(pi: any, ctx: any, config: ModelsConfig): Pro
88
142
  const roles = discovery.roles;
89
143
  const modelFallback = roleField(agentUser, agentProject, ctx.model?.provider, undefined, "model");
90
144
  const thinkingFallback = roleField(agentUser, agentProject, ctx.model?.provider, undefined, "thinking");
91
- const options = [
92
- `Scope: ${scope} (select to change)`,
93
- `Pi session: ${main.provider ?? "auto"}/${main.model ?? "auto"} | ${main.effort ?? "automatic"} [${main.source}]`,
94
- `Subagent fallback: ${display(modelFallback.value)} | ${display(thinkingFallback.value)}`,
95
- ...roles.map(role => {
96
- const model = roleField(agentUser, agentProject, ctx.model?.provider, role.name, "model");
97
- const effort = roleField(agentUser, agentProject, ctx.model?.provider, role.name, "thinking");
98
- return `${role.name}: ${display(model.value ?? role.model)} | ${display(effort.value ?? role.thinking)} [${model.source}; ${effort.source}]${role.runner ? " [external runner]" : ""}`;
99
- }),
100
- ...(discovery.warning ? ["Agent discovery details"] : []),
101
- "Done",
102
- ];
103
- const selected = await ctx.ui.select(`BMS defaults\nSaved settings, not running-child state. Agent changes require /reload.\n${scope === "user" ? settingsPath(ctx.cwd, scope) : "Pi: " + settingsPath(ctx.cwd, scope) + "\nAgents: " + settingsPath(discovery.projectRoot, scope)}`, options);
104
- if (!selected || selected === "Done") return;
105
- const index = options.indexOf(selected);
145
+ const selected = await tableSelect(ctx, {
146
+ title: "BMS defaults",
147
+ search: false,
148
+ state,
149
+ hint: `Scope: ${scope}. Saved settings, not running children. Agent changes require /reload.\nPi: ${settingsPath(ctx.cwd, scope)}\nAgents: ${settingsPath(discovery.projectRoot, scope)}`,
150
+ columns: [{ key: "target", label: "TARGET" }, { key: "model", label: "MODEL" }, { key: "effort", label: "EFFORT" }, { key: "source", label: "SOURCE" }],
151
+ rows: [
152
+ { value: "0", label: `Scope: ${scope}`, cells: { target: "Scope", model: scope, effort: "", source: "Select to change" } },
153
+ { value: "1", label: "Pi session", cells: { target: "Pi session", model: `${main.provider ?? "auto"}/${main.model ?? "auto"}`, effort: main.effort ?? "automatic", source: main.source } },
154
+ { value: "2", label: "Subagent fallback", cells: { target: "Subagent fallback", model: display(modelFallback.value), effort: display(thinkingFallback.value), source: `${modelFallback.source}; ${thinkingFallback.source}` } },
155
+ ...roles.map((role, index) => {
156
+ const model = roleField(agentUser, agentProject, ctx.model?.provider, role.name, "model");
157
+ const effort = roleField(agentUser, agentProject, ctx.model?.provider, role.name, "thinking");
158
+ 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}` } };
159
+ }),
160
+ ...(discovery.warning ? [{ value: "details", label: "Agent discovery details" }] : []),
161
+ { value: "done", label: "Done" },
162
+ ],
163
+ });
164
+ if (selected === undefined || selected === "done") return;
165
+ const index = Number(selected);
106
166
  if (index === 0) {
107
167
  if (!trusted(pi, ctx)) { ctx.ui.notify("Project defaults require a trusted project.", "warning"); continue; }
108
168
  scope = scope === "user" ? "project" : "user";
109
169
  } else if (index === 1) {
110
- const model = await assignModel(ctx, config, "Choose Pi default model");
111
- if (model) await setMainDefault(pi, ctx, model.provider, model.modelId, scope);
112
- } else if (selected === "Agent discovery details") {
170
+ await editPiDefault(pi, ctx, config, scope, user, project);
171
+ } else if (selected === "details") {
113
172
  ctx.ui.notify(discovery.warning!, "warning");
114
173
  } else {
115
174
  const role = index === 2 ? undefined : roles[index - 3];
@@ -124,26 +183,55 @@ async function editRole(pi: any, ctx: any, config: ModelsConfig, scope: Scope, r
124
183
  ctx.ui.notify("External runner defaults are not editable with the Pi registry picker. Use the runner's own configuration.", "warning");
125
184
  return;
126
185
  }
127
- const action = await ctx.ui.select(`${title}\nScope: ${scope}. Provider-specific and project overrides may take precedence.\n${path}`, ["Choose model", "Inherit parent model", "Remove model override", "Choose effort", "Remove effort override", "Back"]);
128
- if (!action || action === "Back") return;
129
- let field: "model" | "thinking" = "model";
130
- let value: string | false | undefined;
131
- if (action === "Choose model") {
132
- const selected = await assignModel(ctx, config, `Choose model for ${title}`);
133
- if (!selected) return;
134
- value = `${selected.provider}/${selected.modelId}`;
135
- } else if (action === "Inherit parent model") value = "inherit";
136
- else if (action === "Choose effort") {
137
- field = "thinking";
138
- const selected = roleField(user, project, ctx.model?.provider, role?.name, "model").value ?? role?.model;
139
- const ref = typeof selected === "string" && selected !== "inherit" ? selected.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, "") : `${ctx.model?.provider}/${ctx.model?.id}`;
140
- const split = ref.indexOf("/");
141
- const model = split > 0 ? ctx.modelRegistry.find(ref.slice(0, split), ref.slice(split + 1)) : undefined;
142
- const effort = await chooseEffort(ctx, ref, model);
143
- if (effort === undefined) return;
144
- value = effort === "automatic" ? undefined : effort;
145
- } else if (action === "Remove effort override") field = "thinking";
146
- if (!(await ctx.ui.confirm(`Save ${title}?`, `${field}: ${value ?? "remove override"}\n${path}\nReload Pi before relying on the new child defaults. Removing effort restores agent-defined/fallback behavior; it does not force parent inheritance.`))) return;
147
- patchSettings(path, data => setRoleField(data, role?.name, field, value));
148
- ctx.ui.notify(`Saved ${title} ${field}. Run /reload to refresh subagent defaults.`, "info");
186
+ const local = scope === "user" ? user : project;
187
+ const entry = role ? local.subagents?.agentOverrides?.[role.name] : undefined;
188
+ let model: string | undefined = role ? entry?.model : local.subagents?.defaultModel;
189
+ let effort: string | false | undefined = role ? entry?.thinking : local.subagents?.defaultThinking;
190
+ let modelChanged = false, effortChanged = false;
191
+ await editForm(ctx, {
192
+ title: `${title} defaults`,
193
+ hint: `Scope: ${scope}\n${path}\nProvider-specific and project overrides still take precedence. Removing effort restores agent-defined/fallback behavior, not parent inheritance.`,
194
+ fields: [
195
+ { key: "model", label: "Model", value: model ?? "remove override", edit: async () => {
196
+ const action = await tableSelect(ctx, { title: `Model for ${title}`, rows: [
197
+ { value: "choose", label: "Choose model" },
198
+ { value: "inherit", label: "Inherit parent model", description: "Explicitly use the parent session model." },
199
+ { value: "remove", label: "Remove model override", description: `Remove only the ${scope} ${title} model override.` },
200
+ ] });
201
+ if (action === undefined) return;
202
+ if (action === "choose") {
203
+ const selected = await assignModel(ctx, config, `Choose model for ${title}`);
204
+ if (!selected) return;
205
+ model = `${selected.provider}/${selected.modelId}`;
206
+ } else if (action === "inherit") model = "inherit";
207
+ else {
208
+ if (!(await confirmAction(ctx, { title: "Remove model override?", lines: [`Target: ${title}`, `Scope: ${scope}`, path], confirmLabel: "Remove override" }))) return;
209
+ model = undefined;
210
+ }
211
+ modelChanged = true;
212
+ return model ?? "remove override";
213
+ } },
214
+ { key: "effort", label: "Effort", value: display(effort), edit: async () => {
215
+ const effective = modelChanged ? model : roleField(user, project, ctx.model?.provider, role?.name, "model").value ?? role?.model;
216
+ const ref = typeof effective === "string" && effective !== "inherit" ? effective.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, "") : `${ctx.model?.provider}/${ctx.model?.id}`;
217
+ const split = ref.indexOf("/");
218
+ const selected = await chooseEffort(ctx, ref, split > 0 ? ctx.modelRegistry.find(ref.slice(0, split), ref.slice(split + 1)) : undefined, effort);
219
+ if (selected === undefined) return;
220
+ if (selected === "automatic" && !(await confirmAction(ctx, { title: "Remove effort override?", lines: [`Target: ${title}`, `Scope: ${scope}`, path, "Restores agent-defined/fallback behavior."], confirmLabel: "Remove override" }))) return;
221
+ effort = selected === "automatic" ? undefined : selected;
222
+ effortChanged = true;
223
+ return effort ?? "remove override";
224
+ } },
225
+ ],
226
+ onSave: async () => {
227
+ if (scope === "project" && !trusted(pi, ctx)) throw new Error("Project is not trusted");
228
+ if (!modelChanged && !effortChanged) return true;
229
+ patchSettings(path, data => {
230
+ if (modelChanged) setRoleField(data, role?.name, "model", model);
231
+ if (effortChanged) setRoleField(data, role?.name, "thinking", effort);
232
+ });
233
+ ctx.ui.notify(`Saved ${title}. Run /reload to refresh subagent defaults.`, "info");
234
+ return true;
235
+ },
236
+ });
149
237
  }