pi-better-model-select 0.1.4 → 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/extensions/bms.ts CHANGED
@@ -4,10 +4,13 @@
4
4
  // with add / update / delete wired to ctrl-chords so plain typing always goes
5
5
  // to the search box.
6
6
 
7
- import { DynamicBorder, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
- import { Container, Text } from "@earendil-works/pi-tui";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { defaultsFlow, piPreference, setMainDefault } from "./lib/defaults.ts";
9
+ import { readSettings, settingsPath } from "./lib/defaults-store.ts";
10
+ import { pickerDialog } from "./lib/picker-dialog.ts";
11
+ import { confirmAction, editForm, tableSelect } from "./lib/tui.ts";
9
12
  import { addFlow, deleteFlow, updateFlow, type FlowCtx } from "./lib/flows.ts";
10
- import { ModelPicker, rankModels, type PickerAction } from "./lib/picker.ts";
13
+ import { rankModels } from "./lib/picker.ts";
11
14
  import {
12
15
  loadConfig,
13
16
  loadConfigFile,
@@ -76,51 +79,50 @@ function syncLocalConfig(pi: ExtensionAPI, ctx: any): ModelsConfig | null {
76
79
  return effective;
77
80
  }
78
81
 
79
- /** Re-read model configuration from disk and switch the session to the given model. */
82
+ /** Stage option changes until the user explicitly saves. */
80
83
  async function optionsFlow(ctx: any, config: ModelsConfig): Promise<void> {
81
- for (;;) {
82
- const settings = loadSettings();
83
- const writeLabel =
84
- settings.writeMode === "overlay" ? "BMS overlay (models.bms.json)" : "Pi config (models.json)";
85
- const hidden = settings.disabledProviders.length;
86
- const pick = await ctx.ui.select("BMS options", [
87
- `Write mode: ${writeLabel}`,
88
- `Provider filters: ${hidden} hidden`,
89
- "Done",
90
- ]);
91
- if (!pick || pick === "Done") return;
92
-
93
- if (pick.startsWith("Write mode:")) {
94
- const mode = await ctx.ui.select("Where should BMS write model changes?", [
95
- "BMS overlay (models.bms.json)",
96
- "Pi config (models.json)",
97
- ]);
98
- if (!mode) continue;
99
- settings.writeMode = mode.startsWith("BMS overlay") ? "overlay" : "direct";
100
- saveSettings(settings);
101
- ctx.ui.notify(`BMS write mode: ${settings.writeMode}.`, "info");
102
- continue;
103
- }
104
-
105
- const providers = Object.keys(config.providers ?? {}).sort();
106
- for (;;) {
107
- const current = loadSettings();
108
- const options = providers.map((provider) =>
109
- `${current.disabledProviders.includes(provider) ? "[ ]" : "[x]"} ${provider}`,
110
- );
111
- const choice = await ctx.ui.select(
112
- "Providers shown in /bms (select to toggle, ESC when done)",
113
- options,
114
- );
115
- if (!choice) break;
116
- const provider = choice.slice(4);
117
- const disabled = new Set(current.disabledProviders);
118
- if (disabled.has(provider)) disabled.delete(provider);
119
- else disabled.add(provider);
120
- current.disabledProviders = [...disabled].sort();
121
- saveSettings(current);
122
- }
123
- }
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
+ });
124
126
  }
125
127
 
126
128
  async function loadModel(
@@ -165,6 +167,33 @@ export default function (pi: ExtensionAPI) {
165
167
  syncLocalConfig(pi, ctx);
166
168
  });
167
169
 
170
+ const report = async (ctx: any, action: () => Promise<void>) => {
171
+ try { await action(); } catch (error) { ctx.ui.notify(`BMS: ${error}`, "error"); }
172
+ };
173
+ const addModel = async (ctx: any) => addFlow(ctx, async (provider, modelId) => {
174
+ if (syncLocalConfig(pi, ctx)) await report(ctx, () => setMainDefault(pi, ctx, provider, modelId));
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
+ });
186
+ pi.registerCommand("bms-defaults", {
187
+ description: "Configure Pi and subagent default models and effort levels",
188
+ handler: async (_args, ctx: any) => {
189
+ if (ctx.mode !== "tui") { ctx.ui.notify("/bms-defaults needs an interactive terminal.", "warning"); return; }
190
+ await report(ctx, async () => {
191
+ const config = syncLocalConfig(pi, ctx);
192
+ if (config) await defaultsFlow(pi, ctx, config);
193
+ });
194
+ },
195
+ });
196
+
168
197
  pi.registerCommand("bms", {
169
198
  description: "Better model select — searchable, usage-ranked model picker",
170
199
  handler: async (_args, ctx: any) => {
@@ -173,7 +202,8 @@ export default function (pi: ExtensionAPI) {
173
202
  return;
174
203
  }
175
204
 
176
- // 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 } = {};
177
207
  for (;;) {
178
208
  const config = syncLocalConfig(pi, ctx);
179
209
  if (!config) return;
@@ -200,41 +230,31 @@ export default function (pi: ExtensionAPI) {
200
230
  "The effective model configuration has no models. Add one now?",
201
231
  );
202
232
  if (!add) return;
203
- await addFlow(ctx as FlowCtx);
233
+ await report(ctx, () => addModel(ctx));
204
234
  continue;
205
235
  }
206
236
 
207
- const action: PickerAction | undefined = await ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) => {
208
- const container = new Container();
209
- const accent = (s: string) =>
210
- typeof theme?.fg === "function" ? theme.fg("accent", s) : s;
211
-
212
- container.addChild(new DynamicBorder(accent));
213
- container.addChild(new Text(accent("Better Model Select"), 1, 0));
214
-
215
- const picker = new ModelPicker(
216
- rows,
217
- theme,
218
- () => tui.requestRender(),
219
- (a: PickerAction) => done(a),
220
- );
221
- picker.focused = true;
222
- container.addChild(picker);
223
- container.addChild(new DynamicBorder(accent));
224
-
225
- return {
226
- render: (w: number) => container.render(w),
227
- invalidate: () => container.invalidate(),
228
- handleInput: (data: string) => picker.handleInput(data),
229
- };
230
- });
237
+ try {
238
+ const preference = piPreference(readSettings(settingsPath(ctx.cwd, "user")), ctx.isProjectTrusted?.() ? readSettings(settingsPath(ctx.cwd, "project")) : {});
239
+ for (const row of rows) {
240
+ row.current = ctx.model?.provider === row.provider && ctx.model?.id === row.modelId;
241
+ row.default = preference.provider === row.provider && preference.model === row.modelId;
242
+ }
243
+ } catch (error) { ctx.ui.notify(`Cannot read defaults: ${error}`, "warning"); }
244
+ const action = await pickerDialog(ctx, rows, "Models", false, { placement: settings.resultPlacement, state: pickerState });
231
245
 
232
246
  switch (action?.kind) {
233
247
  case "load":
234
248
  await loadModel(pi, ctx, action.provider, action.modelId);
235
249
  return;
250
+ case "set-default":
251
+ await report(ctx, () => setMainDefault(pi, ctx, action.provider, action.modelId));
252
+ break;
253
+ case "defaults":
254
+ await report(ctx, () => defaultsFlow(pi, ctx, config));
255
+ break;
236
256
  case "add":
237
- await addFlow(ctx as FlowCtx);
257
+ await report(ctx, () => addModel(ctx));
238
258
  break;
239
259
  case "update":
240
260
  await updateFlow(
@@ -256,10 +276,11 @@ export default function (pi: ExtensionAPI) {
256
276
  await optionsFlow(ctx, config);
257
277
  break;
258
278
  case "reset": {
259
- const ok = await ctx.ui.confirm(
260
- "Reset usage statistics?",
261
- "Model ranking falls back to alphabetical order.",
262
- );
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
+ });
263
284
  if (ok) {
264
285
  saveUsage({ version: 1, counts: {}, lastUsed: {} });
265
286
  ctx.ui.notify("Usage statistics cleared.", "info");
@@ -276,6 +297,6 @@ export default function (pi: ExtensionAPI) {
276
297
  // Standalone add, kept for muscle memory and non-interactive convenience.
277
298
  pi.registerCommand("bms-add", {
278
299
  description: "Add a model from a provider's endpoint using the configured write mode",
279
- handler: async (_args, ctx: any) => addFlow(ctx as FlowCtx),
300
+ handler: async (_args, ctx: any) => report(ctx, () => addModel(ctx)),
280
301
  });
281
302
  }
@@ -0,0 +1,60 @@
1
+ // Optional, isolated adapter: pi-subagents currently has no public discovery RPC.
2
+ // Never vendor a second agent-discovery implementation or execute an agent to list roles.
3
+ import { createRequire } from "node:module";
4
+ import { readFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
8
+
9
+ export interface AgentRole {
10
+ name: string;
11
+ model?: string;
12
+ thinking?: string | false;
13
+ runner?: unknown;
14
+ source?: string;
15
+ modelSource?: unknown;
16
+ }
17
+ export interface AgentDiscovery {
18
+ roles: AgentRole[];
19
+ projectRoot: string;
20
+ warning?: string;
21
+ }
22
+ /** Resolve the owning package, not the directory of its possibly bundled entry point. */
23
+ export function subagentsPackageRoot(entry: string): string {
24
+ let directory = dirname(entry);
25
+ for (;;) {
26
+ try {
27
+ const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
28
+ if (manifest.name === "pi-subagents") return directory;
29
+ } catch { /* keep looking above a bundle or other entry-point directory */ }
30
+ const parent = dirname(directory);
31
+ if (parent === directory) throw new Error("Cannot locate pi-subagents package root");
32
+ directory = parent;
33
+ }
34
+ }
35
+ export async function discoverRoles(cwd: string, pi: any, provider?: string, trusted = false): Promise<AgentDiscovery> {
36
+ try {
37
+ // Resolve only from this extension or the user's trusted Pi package directory.
38
+ const roots = [import.meta.url, pathToFileURL(join(getAgentDir(), "npm", "package.json")).href];
39
+ let entry: string | undefined;
40
+ for (const root of roots) {
41
+ try { entry = createRequire(root).resolve("pi-subagents"); break; } catch { /* optional package */ }
42
+ }
43
+ if (!entry) throw new Error("pi-subagents is not resolvable from BMS or the Pi npm package directory");
44
+ const base = subagentsPackageRoot(entry);
45
+ const api = await import(pathToFileURL(join(base, "src/agents/agents.js")).href);
46
+ if (typeof api.discoverAgents !== "function") throw new Error("Unsupported pi-subagents discovery API");
47
+ api.clearAgentDiscoveryCache?.();
48
+ const result = api.discoverAgents(cwd, trusted ? "both" : "user", provider, { projectTrusted: trusted });
49
+ let roles: AgentRole[] = result.agents;
50
+ try {
51
+ const runtime = await import(pathToFileURL(join(base, "src/agents/runtime-agent-registry.js")).href);
52
+ roles = runtime.mergeRuntimeAgents(pi, result, undefined, { cwd, scope: trusted ? "both" : "user", preferredModelProvider: provider, projectTrusted: trusted }).agents;
53
+ } catch { /* older versions have filesystem agents only */ }
54
+ // Native Pi and subagents may choose different roots. Use the subagents settings root for agents.
55
+ const projectRoot = api.findConfiguredProjectRoot?.(cwd) ?? cwd;
56
+ return { roles: [...roles].sort((a, b) => a.name.localeCompare(b.name)), projectRoot };
57
+ } catch (error) {
58
+ return { roles: [], projectRoot: cwd, warning: `Agent discovery unavailable: ${error}. Pi defaults remain available.` };
59
+ }
60
+ }
@@ -0,0 +1,15 @@
1
+ import { rankModels, type RankedModel } from "./picker.ts";
2
+ import type { ModelsConfig } from "./store.ts";
3
+
4
+ /** Assignment menus include authenticated registry models, not only custom models.json entries. */
5
+ export function rankModelsForDefaults(config: ModelsConfig, ctx: any): RankedModel[] {
6
+ const rows = rankModels(config);
7
+ const seen = new Set(rows.map(row => `${row.provider}/${row.modelId}`));
8
+ for (const model of ctx.modelRegistry.getAvailable?.() ?? []) {
9
+ const key = `${model.provider}/${model.id}`;
10
+ if (seen.has(key)) continue;
11
+ seen.add(key);
12
+ rows.push({ provider: model.provider, modelId: model.id, label: model.name ?? model.id, count: 0, lastUsed: 0, reasoning: model.reasoning, contextWindow: model.contextWindow });
13
+ }
14
+ return rows;
15
+ }
@@ -0,0 +1,108 @@
1
+ // Defaults use native Pi settings. BMS capability hints live separately from models.json.
2
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ export const EFFORTS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
8
+ export type Effort = typeof EFFORTS[number];
9
+ export type Scope = "user" | "project";
10
+ export type JsonObject = Record<string, any>;
11
+ export function isEffort(value: unknown): value is Effort {
12
+ return typeof value === "string" && (EFFORTS as readonly string[]).includes(value);
13
+ }
14
+ export function object(value: unknown): JsonObject {
15
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Expected a JSON object");
16
+ return value as JsonObject;
17
+ }
18
+ export function readSettings(path: string): JsonObject {
19
+ if (!existsSync(path)) return {};
20
+ return object(JSON.parse(readFileSync(path, "utf8")));
21
+ }
22
+ /** Fail closed on malformed files; serialize BMS writers and re-read immediately before patching. */
23
+ export function patchSettings(path: string, patch: (settings: JsonObject) => void): void {
24
+ mkdirSync(dirname(path), { recursive: true });
25
+ const lock = `${path}.bms-lock`;
26
+ const fd = openSync(lock, "wx", 0o600);
27
+ const tmp = `${path}.bms-${randomUUID()}`;
28
+ try {
29
+ const settings = readSettings(path);
30
+ patch(settings);
31
+ writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
32
+ renameSync(tmp, path);
33
+ } finally {
34
+ if (existsSync(tmp)) unlinkSync(tmp);
35
+ closeSync(fd);
36
+ unlinkSync(lock);
37
+ }
38
+ }
39
+ export function settingsPath(cwd: string, scope: Scope, agentDir = getAgentDir()): string {
40
+ return scope === "user" ? join(agentDir, "settings.json") : join(cwd, ".pi", "settings.json");
41
+ }
42
+ export interface Capability {
43
+ levels: Effort[];
44
+ source: "provider" | "manual" | "pi";
45
+ }
46
+ export function metadataPath(): string { return join(getAgentDir(), "bms-model-metadata.json"); }
47
+ export function normalizeEfforts(raw: unknown): Effort[] | undefined {
48
+ if (!Array.isArray(raw) || !raw.length || !raw.every(isEffort)) return undefined;
49
+ return EFFORTS.filter(level => raw.includes(level));
50
+ }
51
+ export function capabilities(key: string, model?: { reasoning?: boolean; thinkingLevelMap?: Record<string, unknown> }, path = metadataPath()): { levels: Effort[]; source: string } {
52
+ const stored = readSettings(path)[key];
53
+ const levels = normalizeEfforts(stored?.levels);
54
+ if (levels) return { levels, source: stored.source === "provider" ? "Provider metadata" : stored.source === "manual" ? "Manual override" : "Pi standard levels; provider support unverified" };
55
+ if (model?.reasoning === false) return { levels: ["off"], source: "Non-reasoning model" };
56
+ // Native maps explicitly mark unsupported Pi levels with null.
57
+ if (model?.thinkingLevelMap) {
58
+ const supported = EFFORTS.filter(level => model.thinkingLevelMap![level] !== null);
59
+ if (supported.length) return { levels: supported, source: "Pi model capability map" };
60
+ }
61
+ return { levels: [...EFFORTS], source: "Pi standard levels; provider support unverified" };
62
+ }
63
+ export function saveCapability(key: string, capability: Capability, path = metadataPath()): void {
64
+ if (!normalizeEfforts(capability.levels)) throw new Error("Choose at least one valid effort level");
65
+ patchSettings(path, data => { data[key] = capability; });
66
+ }
67
+ export function setPiDefault(data: JsonObject, provider: string, model: string, effort: Effort | undefined): void {
68
+ data.defaultProvider = provider;
69
+ data.defaultModel = model;
70
+ if (effort === undefined) delete data.defaultThinkingLevel;
71
+ else data.defaultThinkingLevel = effort;
72
+ // A per-model startup preference would otherwise beat the newly chosen default.
73
+ if (data.modelThinkingLevels !== undefined) {
74
+ const levels = object(data.modelThinkingLevels);
75
+ delete levels[`${provider}/${model}`];
76
+ }
77
+ }
78
+ export function setRoleField(data: JsonObject, role: string | undefined, field: "model" | "thinking", value: string | false | undefined): void {
79
+ if (role && ["__proto__", "constructor", "prototype"].includes(role)) throw new Error("Invalid agent name");
80
+ const sub = data.subagents === undefined ? data.subagents = {} : object(data.subagents);
81
+ if (!role) {
82
+ const key = field === "model" ? "defaultModel" : "defaultThinking";
83
+ if (value === undefined) delete sub[key];
84
+ else sub[key] = value;
85
+ return;
86
+ }
87
+ const overrides = sub.agentOverrides === undefined ? sub.agentOverrides = {} : object(sub.agentOverrides);
88
+ const entry = Object.hasOwn(overrides, role) ? object(overrides[role]) : overrides[role] = {};
89
+ if (value === undefined) delete entry[field];
90
+ else entry[field] = value;
91
+ if (!Object.keys(entry).length) delete overrides[role];
92
+ }
93
+ /** Settings-only projection for the menu, not a claim about a running child. */
94
+ export function roleField(user: JsonObject, project: JsonObject, provider: string | undefined, role: string | undefined, field: "model" | "thinking"): { value: unknown; source: string } {
95
+ for (const [scope, data] of [["Project", project], ["User", user]] as const) {
96
+ const sub = data.subagents;
97
+ if (role) {
98
+ const scoped = provider && sub?.agentOverridesByProvider?.[provider]?.[role];
99
+ if (scoped && Object.hasOwn(scoped, field)) return { value: scoped[field], source: `${scope}, provider override` };
100
+ const regular = sub?.agentOverrides?.[role];
101
+ if (regular && Object.hasOwn(regular, field)) return { value: regular[field], source: `${scope} role override` };
102
+ } else {
103
+ const key = field === "model" ? "defaultModel" : "defaultThinking";
104
+ if (sub && Object.hasOwn(sub, key)) return { value: sub[key], source: `${scope} fallback` };
105
+ }
106
+ }
107
+ return { value: undefined, source: role ? "Agent-defined / fallback" : "Parent session" };
108
+ }