pi-better-model-select 0.3.3 → 0.3.5
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 +21 -0
- package/README.md +14 -10
- package/docs/demo/showcase.gif +0 -0
- package/docs/{configuration.md → public/configuration.md} +2 -2
- package/extensions/bms.ts +25 -7
- package/extensions/lib/defaults-store.ts +6 -6
- package/extensions/lib/defaults.ts +99 -22
- package/extensions/lib/effort-editor.ts +3 -3
- package/extensions/lib/flows.ts +4 -4
- package/extensions/lib/picker.ts +24 -19
- package/extensions/lib/tui.ts +22 -11
- package/icon.svg +10 -0
- package/package.json +4 -3
- package/docs/development.md +0 -104
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.5
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Include the subtle-highlight Better Model Select icon in the npm package and display it at the top of the README.
|
|
8
|
+
- Set the Pi package gallery preview image to the packaged icon.
|
|
9
|
+
- Preserve the original icon design sheet in docs/design as a repository-only reference.
|
|
10
|
+
|
|
11
|
+
## 0.3.4
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
- Separate packaged user documentation in docs/public from internal development and menu-design documents in docs/internal.
|
|
18
|
+
- 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.
|
|
19
|
+
- Replace status text with separate DEFAULT and CURRENT checkmark columns.
|
|
20
|
+
- Show an EFFORT column for every search result before loading, using saved preferences or the session fallback. Non-reasoning models show off.
|
|
21
|
+
- 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.
|
|
22
|
+
- Update the smoke test for the save notification's settings path.
|
|
23
|
+
|
|
3
24
|
## 0.3.3
|
|
4
25
|
|
|
5
26
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<img src="https://cdn.jsdelivr.net/npm/pi-better-model-select@latest/icon.svg" alt="Better Model Select icon" width="96" height="96">
|
|
2
|
+
|
|
1
3
|
# pi-better-model-select
|
|
2
4
|
|
|
3
5
|
A searchable model picker for [Pi](https://pi.dev). Switch models, edit model definitions, and save default models and thinking effort. Models you pick most often appear first.
|
|
@@ -12,9 +14,9 @@ Run `/bms`, type to filter by name, model ID, or provider, then press Enter to s
|
|
|
12
14
|
|
|
13
15
|
## Compact TUI
|
|
14
16
|
|
|
15
|
-

|
|
16
18
|
|
|
17
|
-
The picker uses aligned model, provider, context, usage, and
|
|
19
|
+
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
20
|
|
|
19
21
|
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
22
|
|
|
@@ -33,26 +35,28 @@ Inside `/bms`:
|
|
|
33
35
|
| --- | --- |
|
|
34
36
|
| ↑ / ↓ | Move through models |
|
|
35
37
|
| Enter | Use the selected model for this session |
|
|
36
|
-
| Ctrl+D |
|
|
38
|
+
| Ctrl+D | Open startup-default form for the highlighted model |
|
|
39
|
+
| Ctrl+E | Edit the highlighted model's effort preference |
|
|
37
40
|
| Ctrl+G | Open the defaults menu |
|
|
38
41
|
| Ctrl+A / Ctrl+U / Ctrl+X | Add / update / delete a model |
|
|
39
|
-
| Ctrl+O | Open
|
|
42
|
+
| Ctrl+O | Open BMS menu: Models, Add model, Defaults, Options |
|
|
40
43
|
| Ctrl+R | Reset usage ranking |
|
|
41
44
|
| Esc | Close |
|
|
42
45
|
|
|
43
|
-
The
|
|
46
|
+
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
47
|
|
|
45
48
|
## Default models and effort
|
|
46
49
|
|
|
47
50
|
Save a model and thinking effort for Pi, a subagent fallback, or an individual agent role.
|
|
48
51
|
|
|
49
|
-
- Use Ctrl+D in `/bms`
|
|
52
|
+
- 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.
|
|
53
|
+
- Configure the user-wide fallback effort in `/bms-defaults` → Pi fallback effort. Per-model preferences take priority.
|
|
50
54
|
- 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
55
|
- Agent model and effort overrides are independent. You can inherit the parent model while choosing a different effort.
|
|
52
56
|
|
|
53
57
|
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
58
|
|
|
55
|
-
Run `/reload` after changing agent defaults or per-model
|
|
59
|
+
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
60
|
|
|
57
61
|
### Optional pi-subagents integration
|
|
58
62
|
|
|
@@ -74,7 +78,7 @@ BMS uses provider-reported effort lists when available and Pi's capability restr
|
|
|
74
78
|
off · minimal · low · medium · high · xhigh · max
|
|
75
79
|
```
|
|
76
80
|
|
|
77
|
-
Add/update
|
|
81
|
+
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
82
|
|
|
79
83
|
## Where changes are saved
|
|
80
84
|
|
|
@@ -92,8 +96,8 @@ Provider filters hide entries only in `/bms`, not Pi's `/model` picker. Default-
|
|
|
92
96
|
## Further documentation
|
|
93
97
|
|
|
94
98
|
- [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@
|
|
96
|
-
|
|
99
|
+
- [Configuration reference](https://cdn.jsdelivr.net/npm/pi-better-model-select@latest/docs/public/configuration.md): file paths, precedence, overlays, metadata, and integration limits.
|
|
100
|
+
Development and navigation design documents live in `docs/internal/` in the source checkout and are not included in the npm package.
|
|
97
101
|
|
|
98
102
|
## License
|
|
99
103
|
|
package/docs/demo/showcase.gif
CHANGED
|
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
|
-
|
|
|
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
|
|
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,
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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, () =>
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
delete
|
|
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 =
|
|
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,
|
|
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" =
|
|
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
|
|
106
|
-
hint: `Scope: ${scope}\n${path}\nSave
|
|
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
|
|
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
|
|
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;
|
|
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
|
|
221
|
+
title: "BMS / Defaults",
|
|
153
222
|
search: false,
|
|
154
223
|
state,
|
|
155
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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; }
|
package/extensions/lib/flows.ts
CHANGED
|
@@ -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", "
|
|
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
|
|
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);
|
package/extensions/lib/picker.ts
CHANGED
|
@@ -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 = "
|
|
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:
|
|
116
|
-
{ key: "
|
|
117
|
-
{ key: "
|
|
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),
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
{ value: "
|
|
181
|
-
{ value: "
|
|
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: "
|
|
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[] {
|
|
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
|
}
|
package/extensions/lib/tui.ts
CHANGED
|
@@ -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 ? [] :
|
|
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"
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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/icon.svg
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-labelledby="title desc">
|
|
2
|
+
<title id="title">Better Model Select</title>
|
|
3
|
+
<desc id="desc">A dark rounded tile with an orange outlined selected row and chevron above amber and blue model rows.</desc>
|
|
4
|
+
<rect width="96" height="96" rx="20" fill="#1F1F1F"/>
|
|
5
|
+
<rect x="11" y="19" width="74" height="24" rx="6" fill="#FF6B3D" fill-opacity="0.2" stroke="#FF6B3D" stroke-width="2"/>
|
|
6
|
+
<rect x="20" y="25" width="42" height="12" rx="3" fill="#FF6B3D"/>
|
|
7
|
+
<path d="M71 26L77 31L71 36" fill="none" stroke="#FF6B3D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
|
8
|
+
<rect x="20" y="52" width="40" height="11" rx="3" fill="#FFB13D"/>
|
|
9
|
+
<rect x="20" y="70" width="24" height="11" rx="3" fill="#5BA7FF"/>
|
|
10
|
+
</svg>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-model-select",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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": [
|
|
@@ -25,13 +25,14 @@
|
|
|
25
25
|
"extensions/",
|
|
26
26
|
"!extensions/**/*.test.ts",
|
|
27
27
|
"README.md",
|
|
28
|
+
"icon.svg",
|
|
28
29
|
"CHANGELOG.md",
|
|
29
|
-
"docs/configuration.md",
|
|
30
|
-
"docs/development.md",
|
|
30
|
+
"docs/public/configuration.md",
|
|
31
31
|
"docs/demo/showcase.gif",
|
|
32
32
|
"LICENSE"
|
|
33
33
|
],
|
|
34
34
|
"pi": {
|
|
35
|
+
"image": "https://cdn.jsdelivr.net/npm/pi-better-model-select@latest/icon.svg",
|
|
35
36
|
"extensions": [
|
|
36
37
|
"./extensions/bms.ts"
|
|
37
38
|
]
|
package/docs/development.md
DELETED
|
@@ -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
|
-
|