pi-better-model-select 0.1.3

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.
@@ -0,0 +1,261 @@
1
+ // The /bms picker: a filter Input stacked on a SelectList.
2
+ //
3
+ // Typing always goes to the search box, so plain letters can never be stolen
4
+ // as action shortcuts. Actions are therefore ctrl-chords, which stay reachable
5
+ // while the cursor sits in the search field.
6
+
7
+ import {
8
+ Container,
9
+ fuzzyFilter,
10
+ Input,
11
+ matchesKey,
12
+ SelectList,
13
+ type Focusable,
14
+ type SelectItem,
15
+ type SelectListTheme,
16
+ Text,
17
+ } from "@earendil-works/pi-tui";
18
+ import { loadUsage, usageKey, type ModelsConfig } from "./store.ts";
19
+
20
+ export type PickerAction =
21
+ | { kind: "load"; provider: string; modelId: string }
22
+ | { kind: "add" }
23
+ | { kind: "update"; provider?: string; modelId?: string }
24
+ | { kind: "delete"; provider?: string; modelId?: string }
25
+ | { kind: "reset" }
26
+ | { kind: "close" };
27
+
28
+ export interface RankedModel {
29
+ provider: string;
30
+ modelId: string;
31
+ label: string;
32
+ count: number;
33
+ lastUsed: number;
34
+ contextWindow?: number;
35
+ reasoning: boolean;
36
+ }
37
+
38
+ /**
39
+ * Rank models by usage: most-selected first, ties broken by most-recent, then
40
+ * alphabetically so the order is stable for never-used models.
41
+ */
42
+ export function rankModels(config: ModelsConfig): RankedModel[] {
43
+ const stats = loadUsage();
44
+ const rows: RankedModel[] = [];
45
+ for (const [provider, entry] of Object.entries(config.providers ?? {})) {
46
+ for (const m of entry.models ?? []) {
47
+ const key = usageKey(provider, m.id);
48
+ rows.push({
49
+ provider,
50
+ modelId: m.id,
51
+ label: m.name ?? m.id,
52
+ count: stats.counts[key] ?? 0,
53
+ lastUsed: stats.lastUsed[key] ?? 0,
54
+ contextWindow: m.contextWindow,
55
+ reasoning: Boolean(m.reasoning),
56
+ });
57
+ }
58
+ }
59
+ rows.sort((a, b) => {
60
+ if (b.count !== a.count) return b.count - a.count;
61
+ if (b.lastUsed !== a.lastUsed) return b.lastUsed - a.lastUsed;
62
+ return a.label.localeCompare(b.label);
63
+ });
64
+ return rows;
65
+ }
66
+
67
+ function formatContext(n?: number): string {
68
+ if (!n) return "";
69
+ return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
70
+ }
71
+
72
+ export function toSelectItem(row: RankedModel): SelectItem {
73
+ const bits: string[] = [row.modelId, `[${row.provider}]`];
74
+ const ctxLabel = formatContext(row.contextWindow);
75
+ if (ctxLabel) bits.push(`ctx ${ctxLabel}`);
76
+ if (row.reasoning) bits.push("reasoning");
77
+ if (row.count > 0) bits.push(`used ${row.count}x`);
78
+ return {
79
+ value: `${row.provider}\u0000${row.modelId}`,
80
+ label: row.label,
81
+ description: bits.join(" • "),
82
+ };
83
+ }
84
+
85
+ export function decodeValue(value: string): { provider: string; modelId: string } | null {
86
+ const idx = value.indexOf("\u0000");
87
+ if (idx < 0) return null;
88
+ return { provider: value.slice(0, idx), modelId: value.slice(idx + 1) };
89
+ }
90
+
91
+ export function selectListTheme(theme: any): SelectListTheme {
92
+ const fg =
93
+ typeof theme?.fg === "function"
94
+ ? (role: string, t: string) => theme.fg(role, t)
95
+ : (_role: string, t: string) => t;
96
+ return {
97
+ selectedPrefix: (t: string) => fg("accent", t),
98
+ selectedText: (t: string) => fg("accent", t),
99
+ description: (t: string) => fg("muted", t),
100
+ scrollInfo: (t: string) => fg("dim", t),
101
+ noMatch: (t: string) => fg("warning", t),
102
+ };
103
+ }
104
+
105
+ const HINT =
106
+ "↑↓ move • enter load • ctrl+a add • ctrl+u update • ctrl+x delete • ctrl+r reset stats • esc close";
107
+
108
+ export class ModelPicker extends Container implements Focusable {
109
+ private search: Input;
110
+ private list: SelectList;
111
+ private rows: RankedModel[];
112
+ private filtered: RankedModel[];
113
+ private hintText: Text;
114
+ private countText: Text;
115
+ // Plain fields rather than constructor parameter properties, so the source
116
+ // also loads under type-stripping runtimes that reject that syntax.
117
+ private theme: any;
118
+ private onChange: () => void;
119
+ private done: (action: PickerAction) => void;
120
+
121
+ private _focused = false;
122
+ get focused(): boolean {
123
+ return this._focused;
124
+ }
125
+ // Propagate focus to the Input so the hardware cursor lands correctly for IME.
126
+ set focused(value: boolean) {
127
+ this._focused = value;
128
+ this.search.focused = value;
129
+ }
130
+
131
+ constructor(
132
+ rows: RankedModel[],
133
+ theme: any,
134
+ onChange: () => void,
135
+ done: (action: PickerAction) => void,
136
+ ) {
137
+ super();
138
+ this.theme = theme;
139
+ this.onChange = onChange;
140
+ this.done = done;
141
+ this.rows = rows;
142
+ this.filtered = rows;
143
+
144
+ const fg =
145
+ typeof theme?.fg === "function"
146
+ ? (role: string, t: string) => theme.fg(role, t)
147
+ : (_role: string, t: string) => t;
148
+
149
+ this.search = new Input({
150
+ prompt: " search ",
151
+ placeholder: "type to filter by name, id or provider…",
152
+ placeholderStyle: (t: string) => fg("dim", t),
153
+ });
154
+ this.search.onEscape = () => this.done({ kind: "close" });
155
+ this.search.onSubmit = () => this.loadSelected();
156
+
157
+ this.list = this.buildList(rows);
158
+
159
+ this.countText = new Text(fg("dim", this.countLabel()), 1, 0);
160
+ this.hintText = new Text(fg("dim", HINT), 1, 0);
161
+
162
+ this.addChild(this.search);
163
+ this.addChild(this.list);
164
+ this.addChild(this.countText);
165
+ this.addChild(this.hintText);
166
+ }
167
+
168
+ /** SelectList has no public API to replace its items, so rebuild it. */
169
+ private buildList(rows: RankedModel[]): SelectList {
170
+ const list = new SelectList(
171
+ rows.map(toSelectItem),
172
+ Math.min(Math.max(rows.length, 1), 12),
173
+ selectListTheme(this.theme),
174
+ );
175
+ list.onSelect = () => this.loadSelected();
176
+ list.onCancel = () => this.done({ kind: "close" });
177
+ return list;
178
+ }
179
+
180
+ private countLabel(): string {
181
+ const total = this.rows.length;
182
+ const shown = this.filtered.length;
183
+ return shown === total ? `${total} models` : `${shown} of ${total} models`;
184
+ }
185
+
186
+ private currentRow(): RankedModel | null {
187
+ const item = this.list.getSelectedItem();
188
+ if (!item) return null;
189
+ const decoded = decodeValue(item.value);
190
+ if (!decoded) return null;
191
+ return (
192
+ this.filtered.find(
193
+ (r) => r.provider === decoded.provider && r.modelId === decoded.modelId,
194
+ ) ?? null
195
+ );
196
+ }
197
+
198
+ private loadSelected(): void {
199
+ const row = this.currentRow();
200
+ if (!row) return;
201
+ this.done({ kind: "load", provider: row.provider, modelId: row.modelId });
202
+ }
203
+
204
+ /**
205
+ * Re-filter with fuzzyFilter rather than SelectList.setFilter, which only does
206
+ * a prefix match on item.value and would ignore the display name. Ranking is
207
+ * preserved because fuzzyFilter is stable over the already-sorted rows for
208
+ * equal scores, and exact-prefix hits still surface first.
209
+ */
210
+ private applyFilter(): void {
211
+ const query = this.search.getValue().trim();
212
+ this.filtered = query
213
+ ? fuzzyFilter(this.rows, query, (r) => `${r.label} ${r.modelId} ${r.provider}`)
214
+ : this.rows;
215
+
216
+ // Swap the list child in place; index 1 is the SelectList.
217
+ const next = this.buildList(this.filtered);
218
+ const idx = this.children.indexOf(this.list);
219
+ if (idx >= 0) this.children[idx] = next;
220
+ this.list = next;
221
+
222
+ const fg =
223
+ typeof this.theme?.fg === "function"
224
+ ? (role: string, t: string) => this.theme.fg(role, t)
225
+ : (_role: string, t: string) => t;
226
+ this.countText.setText(fg("dim", this.countLabel()));
227
+ }
228
+
229
+ handleInput(data: string): void {
230
+ if (matchesKey(data, "escape")) return this.done({ kind: "close" });
231
+ if (matchesKey(data, "ctrl+a")) return this.done({ kind: "add" });
232
+ if (matchesKey(data, "ctrl+r")) return this.done({ kind: "reset" });
233
+
234
+ if (matchesKey(data, "ctrl+u")) {
235
+ const row = this.currentRow();
236
+ return this.done({ kind: "update", provider: row?.provider, modelId: row?.modelId });
237
+ }
238
+ if (matchesKey(data, "ctrl+x")) {
239
+ const row = this.currentRow();
240
+ return this.done({ kind: "delete", provider: row?.provider, modelId: row?.modelId });
241
+ }
242
+
243
+ // Navigation and submit belong to the list; everything else is search text.
244
+ if (
245
+ matchesKey(data, "up") ||
246
+ matchesKey(data, "down") ||
247
+ matchesKey(data, "pageUp") ||
248
+ matchesKey(data, "pageDown") ||
249
+ matchesKey(data, "enter")
250
+ ) {
251
+ this.list.handleInput(data);
252
+ this.onChange();
253
+ return;
254
+ }
255
+
256
+ const before = this.search.getValue();
257
+ this.search.handleInput(data);
258
+ if (this.search.getValue() !== before) this.applyFilter();
259
+ this.onChange();
260
+ }
261
+ }
@@ -0,0 +1,205 @@
1
+ // Model configuration access + usage statistics store.
2
+ //
3
+ // Pi reads models.json itself. Better Model Select also supports an optional
4
+ // models.local.json overlay for personal edits. The extension merges and
5
+ // registers that overlay at runtime, so the local file remains private to BMS.
6
+
7
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
8
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ export type ConfigTarget = "models.json" | "models.local.json";
12
+
13
+ export const MODELS_PATH = (): string => join(getAgentDir(), "models.json");
14
+ export const LOCAL_MODELS_PATH = (): string => join(getAgentDir(), "models.local.json");
15
+ export const USAGE_PATH = (): string => join(getAgentDir(), "bms-usage.json");
16
+
17
+ export interface ModelEntry {
18
+ id: string;
19
+ /** BMS overlay tombstone. Hides an inherited model without editing models.json. */
20
+ bmsDeleted?: boolean;
21
+ name?: string;
22
+ reasoning?: boolean;
23
+ input?: string[];
24
+ contextWindow?: number;
25
+ maxTokens?: number;
26
+ [k: string]: unknown;
27
+ }
28
+
29
+ export interface ProviderEntry {
30
+ name?: string;
31
+ baseUrl?: string;
32
+ apiKey?: string;
33
+ api?: string;
34
+ models?: ModelEntry[];
35
+ [k: string]: unknown;
36
+ }
37
+
38
+ export interface ModelsConfig {
39
+ providers?: Record<string, ProviderEntry>;
40
+ [k: string]: unknown;
41
+ }
42
+
43
+ export function configPath(target: ConfigTarget): string {
44
+ return target === "models.local.json" ? LOCAL_MODELS_PATH() : MODELS_PATH();
45
+ }
46
+
47
+ /** Write JSON atomically so a crash mid-write cannot truncate the target file. */
48
+ function writeJsonAtomic(path: string, data: unknown): void {
49
+ const tmp = `${path}.tmp-${process.pid}`;
50
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
51
+ renameSync(tmp, path);
52
+ }
53
+
54
+ function readConfig(path: string): ModelsConfig {
55
+ return JSON.parse(readFileSync(path, "utf8")) as ModelsConfig;
56
+ }
57
+
58
+ export function loadConfigFile(
59
+ ctx: { ui: { notify: (m: string, l: string) => void } },
60
+ target: ConfigTarget,
61
+ missingIsEmpty = false,
62
+ ): ModelsConfig | null {
63
+ const path = configPath(target);
64
+ if (missingIsEmpty && !existsSync(path)) return { providers: {} };
65
+ try {
66
+ return readConfig(path);
67
+ } catch (e) {
68
+ ctx.ui.notify(`Cannot read/parse ${path}: ${e}`, "error");
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /** Merge models.local.json over models.json. Model entries merge by id. */
74
+ export function mergeConfigs(base: ModelsConfig, local: ModelsConfig): ModelsConfig {
75
+ const merged = structuredClone(base);
76
+ merged.providers = merged.providers ?? {};
77
+
78
+ for (const [providerName, localProvider] of Object.entries(local.providers ?? {})) {
79
+ const baseProvider = merged.providers[providerName] ?? {};
80
+ const localModels = localProvider.models;
81
+ const provider = { ...baseProvider, ...structuredClone(localProvider) };
82
+
83
+ if (localModels) {
84
+ const models = new Map<string, ModelEntry>();
85
+ for (const model of baseProvider.models ?? []) models.set(model.id, structuredClone(model));
86
+ for (const model of localModels) {
87
+ if (model.bmsDeleted === true) models.delete(model.id);
88
+ else models.set(model.id, { ...(models.get(model.id) ?? {}), ...structuredClone(model) });
89
+ }
90
+ provider.models = [...models.values()];
91
+ }
92
+
93
+ merged.providers[providerName] = provider;
94
+ }
95
+
96
+ return merged;
97
+ }
98
+
99
+ /** Load the effective config used by the picker and BMS provider overlay. */
100
+ export function loadConfig(ctx: {
101
+ ui: { notify: (m: string, l: string) => void };
102
+ }): ModelsConfig | null {
103
+ const base = loadConfigFile(ctx, "models.json");
104
+ if (!base) return null;
105
+ if (!existsSync(LOCAL_MODELS_PATH())) return base;
106
+ const local = loadConfigFile(ctx, "models.local.json");
107
+ return local ? mergeConfigs(base, local) : null;
108
+ }
109
+
110
+ export function saveConfig(config: ModelsConfig, target: ConfigTarget): void {
111
+ writeJsonAtomic(configPath(target), config);
112
+ }
113
+
114
+ export function configContainsModel(
115
+ config: ModelsConfig,
116
+ provider: string,
117
+ modelId: string,
118
+ ): boolean {
119
+ return Boolean(config.providers?.[provider]?.models?.some((model) => model.id === modelId));
120
+ }
121
+
122
+ /** Hide an inherited model through the BMS local overlay. */
123
+ export function tombstoneModel(config: ModelsConfig, provider: string, modelId: string): void {
124
+ upsertModel(config, provider, { id: modelId, bmsDeleted: true });
125
+ }
126
+
127
+ /** Upsert a complete model entry into one config file. */
128
+ export function upsertModel(
129
+ config: ModelsConfig,
130
+ provider: string,
131
+ model: ModelEntry,
132
+ ): void {
133
+ config.providers = config.providers ?? {};
134
+ const target = (config.providers[provider] = config.providers[provider] ?? {});
135
+ target.models = target.models ?? [];
136
+ const index = target.models.findIndex((entry) => entry.id === model.id);
137
+ if (index >= 0) target.models[index] = model;
138
+ else target.models.push(model);
139
+ }
140
+
141
+ // ----- usage statistics ----------------------------------------------------
142
+
143
+ export interface UsageStats {
144
+ version: 1;
145
+ /** "provider/modelId" -> number of times selected */
146
+ counts: Record<string, number>;
147
+ /** "provider/modelId" -> epoch millis of the last selection */
148
+ lastUsed: Record<string, number>;
149
+ }
150
+
151
+ const EMPTY_USAGE: UsageStats = { version: 1, counts: {}, lastUsed: {} };
152
+
153
+ export function usageKey(provider: string, modelId: string): string {
154
+ return `${provider}/${modelId}`;
155
+ }
156
+
157
+ export function loadUsage(): UsageStats {
158
+ try {
159
+ const raw = JSON.parse(readFileSync(USAGE_PATH(), "utf8")) as Partial<UsageStats>;
160
+ return {
161
+ version: 1,
162
+ counts: raw.counts ?? {},
163
+ lastUsed: raw.lastUsed ?? {},
164
+ };
165
+ } catch {
166
+ // Missing or corrupt stats must never block the picker. Usage data is not
167
+ // a source of truth, so alphabetical ranking is a safe fallback.
168
+ return { ...EMPTY_USAGE, counts: {}, lastUsed: {} };
169
+ }
170
+ }
171
+
172
+ export function saveUsage(stats: UsageStats): void {
173
+ try {
174
+ writeJsonAtomic(USAGE_PATH(), stats);
175
+ } catch {
176
+ // Non-fatal: ranking degrades to alphabetical, the picker still works.
177
+ }
178
+ }
179
+
180
+ /** Record one selection of provider/modelId. */
181
+ export function recordUsage(provider: string, modelId: string): void {
182
+ const stats = loadUsage();
183
+ const key = usageKey(provider, modelId);
184
+ stats.counts[key] = (stats.counts[key] ?? 0) + 1;
185
+ stats.lastUsed[key] = Date.now();
186
+ saveUsage(stats);
187
+ }
188
+
189
+ /** Drop stats for models that no longer exist in the effective config. */
190
+ export function pruneUsage(config: ModelsConfig): void {
191
+ const live = new Set<string>();
192
+ for (const [p, provider] of Object.entries(config.providers ?? {})) {
193
+ for (const m of provider.models ?? []) live.add(usageKey(p, m.id));
194
+ }
195
+ const stats = loadUsage();
196
+ let changed = false;
197
+ for (const key of Object.keys(stats.counts)) {
198
+ if (!live.has(key)) {
199
+ delete stats.counts[key];
200
+ delete stats.lastUsed[key];
201
+ changed = true;
202
+ }
203
+ }
204
+ if (changed) saveUsage(stats);
205
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "pi-better-model-select",
3
+ "version": "0.1.3",
4
+ "type": "module",
5
+ "description": "Better model select for pi — /bms opens a searchable model picker sorted by how often you actually use each model, with inline add, update and delete.",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "bms",
10
+ "model-select",
11
+ "model-manager",
12
+ "model-picker",
13
+ "search",
14
+ "fuzzy",
15
+ "mru",
16
+ "usage-ranking",
17
+ "litellm",
18
+ "openai-compatible"
19
+ ],
20
+ "license": "MIT",
21
+ "files": [
22
+ "extensions/",
23
+ "!extensions/**/*.test.ts",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "pi": {
28
+ "extensions": [
29
+ "./extensions/bms.ts"
30
+ ]
31
+ },
32
+ "scripts": {
33
+ "typecheck": "tsc --noEmit",
34
+ "test": "node --experimental-strip-types --test extensions/lib/*.test.ts",
35
+ "check:pack": "npm pack --dry-run --ignore-scripts >/dev/null",
36
+ "gate": "npm run typecheck && npm test && npm run check:pack"
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-coding-agent": "*",
40
+ "@earendil-works/pi-tui": "*"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "@earendil-works/pi-coding-agent": {
44
+ "optional": true
45
+ },
46
+ "@earendil-works/pi-tui": {
47
+ "optional": true
48
+ }
49
+ },
50
+ "devDependencies": {
51
+ "@earendil-works/pi-coding-agent": "^0.85.1",
52
+ "@earendil-works/pi-tui": "^0.85.1",
53
+ "@types/node": "^22.0.0",
54
+ "typescript": "^6.0.3"
55
+ }
56
+ }