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.
@@ -5,11 +5,20 @@
5
5
  // endpoint yields nothing and the caller falls back to explicit defaults.
6
6
 
7
7
  import type { ModelsConfig } from "./store.ts";
8
+ import { normalizeEfforts, type Effort } from "./defaults-store.ts";
9
+
10
+ /** Only explicit enumerations count. supported_parameters alone is not an effort list. */
11
+ export function metadataEfforts(metadata: Record<string, any>): Effort[] | undefined {
12
+ return normalizeEfforts(metadata.supported_reasoning_efforts)
13
+ ?? normalizeEfforts(metadata.supported_thinking_levels)
14
+ ?? normalizeEfforts(metadata.reasoning_efforts);
15
+ }
8
16
 
9
17
  export const DEFAULT_CONTEXT_WINDOW = 128000;
10
18
  export const DEFAULT_MAX_TOKENS = 32768;
11
19
 
12
20
  export interface ModelMeta {
21
+ effortLevels?: Effort[];
13
22
  contextWindow?: number;
14
23
  maxTokens?: number;
15
24
  reasoning?: boolean;
@@ -55,6 +64,7 @@ export async function fetchModelMeta(
55
64
  key: string,
56
65
  modelId: string,
57
66
  ): Promise<ModelMeta> {
67
+ let effortLevels: Effort[] | undefined;
58
68
  let metaContext: number | undefined;
59
69
  let metaMaxTokens: number | undefined;
60
70
  let metaReasoning: boolean | undefined;
@@ -87,6 +97,8 @@ export async function fetchModelMeta(
87
97
  );
88
98
  if (match?.model_info) {
89
99
  const mi = match.model_info as Record<string, any>;
100
+ effortLevels = metadataEfforts(mi);
101
+ if (typeof mi.supports_reasoning === "boolean") metaReasoning = mi.supports_reasoning;
90
102
  metaContext = mi.max_input_tokens ?? mi.max_tokens;
91
103
  // Only trust an explicit output cap — LiteLLM's generic `max_tokens`
92
104
  // is often a default (e.g. 4096/128000), not the model's real cap.
@@ -106,8 +118,8 @@ export async function fetchModelMeta(
106
118
  // Not a LiteLLM proxy or endpoint unavailable — ignore.
107
119
  }
108
120
 
109
- // 2) OpenAI-style per-model GET
110
- if (metaContext === undefined) {
121
+ // 2) OpenAI-style per-model GET, also used when token metadata lacks effort information.
122
+ if (metaContext === undefined || effortLevels === undefined) {
111
123
  try {
112
124
  const modelRes = await fetch(`${base}/models/${encodeURIComponent(modelId)}`, {
113
125
  headers: authHeaders,
@@ -117,9 +129,10 @@ export async function fetchModelMeta(
117
129
  const b = (await modelRes.json()) as Record<string, any>;
118
130
  // OpenAI /v1/models/{id} does not return token limits, but some proxies
119
131
  // add custom fields like max_context_length or max_model_tokens.
120
- metaContext =
121
- b.max_context_length ?? b.max_input_tokens ?? b.context_window ?? b.max_tokens;
122
- metaMaxTokens = b.max_output_tokens ?? b.max_completion_tokens ?? metaContext;
132
+ effortLevels ??= metadataEfforts(b);
133
+ if (typeof b.reasoning === "boolean") metaReasoning ??= b.reasoning;
134
+ metaContext ??= b.max_context_length ?? b.max_input_tokens ?? b.context_window ?? b.max_tokens;
135
+ metaMaxTokens ??= b.max_output_tokens ?? b.max_completion_tokens;
123
136
  }
124
137
  } catch {
125
138
  // Endpoint not supported — ignore.
@@ -140,7 +153,7 @@ export async function fetchModelMeta(
140
153
  if (!entry) continue;
141
154
  metaContext = entry.max_input_tokens ?? entry.max_tokens;
142
155
  metaMaxTokens = entry.max_output_tokens ?? entry.max_tokens;
143
- metaReasoning =
156
+ metaReasoning ??=
144
157
  entry.mode === "reasoning" || entry.reasoning === true || heuristicReasoning;
145
158
  if (entry.input_cost_per_token > 0 || entry.output_cost_per_token > 0) {
146
159
  metaInput = entry.supports_vision ? ["text", "image"] : ["text"];
@@ -165,7 +178,8 @@ export async function fetchModelMeta(
165
178
  return {
166
179
  contextWindow: metaContext,
167
180
  maxTokens: metaMaxTokens,
168
- reasoning: metaReasoning,
181
+ reasoning: effortLevels?.some(level => level !== "off") ? true : metaReasoning,
182
+ effortLevels,
169
183
  input: metaInput,
170
184
  };
171
185
  }
@@ -0,0 +1,13 @@
1
+ import { ModelPicker, type PickerAction, type PickerOptions, type RankedModel } from "./picker.ts";
2
+ import { resultPlacement } from "./tui.ts";
3
+
4
+ export async function pickerDialog(
5
+ ctx: any, rows: RankedModel[], title = "Better Model Select", assignment = false,
6
+ options: PickerOptions = {},
7
+ ): Promise<PickerAction | undefined> {
8
+ // Return the Focusable itself. Pi's custom UI installs it in the prompt area,
9
+ // calls setFocus on it and restores the previous editor when done is called.
10
+ return ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) =>
11
+ new ModelPicker(rows, theme, () => tui.requestRender(), done, assignment,
12
+ { ...options, placement: options.placement ?? resultPlacement() }, title));
13
+ }
@@ -1,24 +1,14 @@
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.
1
+ // The /bms prompt-area picker. Typing goes to search; ctrl-chords perform
2
+ // actions. The action menu shares this dialog and never replaces its input.
6
3
 
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";
4
+ import { matchesKey, type Focusable, type SelectItem, type SelectListTheme } from "@earendil-works/pi-tui";
5
+ import { TableSelect, type Placement, type SelectionState, type TableColumn } from "./tui.ts";
18
6
  import { loadUsage, usageKey, type ModelsConfig } from "./store.ts";
19
7
 
20
8
  export type PickerAction =
21
9
  | { kind: "load"; provider: string; modelId: string }
10
+ | { kind: "set-default"; provider: string; modelId: string }
11
+ | { kind: "defaults" }
22
12
  | { kind: "add" }
23
13
  | { kind: "update"; provider?: string; modelId?: string }
24
14
  | { kind: "delete"; provider?: string; modelId?: string }
@@ -34,6 +24,8 @@ export interface RankedModel {
34
24
  lastUsed: number;
35
25
  contextWindow?: number;
36
26
  reasoning: boolean;
27
+ current?: boolean;
28
+ default?: boolean;
37
29
  }
38
30
 
39
31
  /**
@@ -77,6 +69,8 @@ function formatContext(n?: number): string {
77
69
 
78
70
  export function toSelectItem(row: RankedModel): SelectItem {
79
71
  const bits: string[] = [row.modelId, `[${row.provider}]`];
72
+ if (row.current) bits.push("[current]");
73
+ if (row.default) bits.push("[default]");
80
74
  const ctxLabel = formatContext(row.contextWindow);
81
75
  if (ctxLabel) bits.push(`ctx ${ctxLabel}`);
82
76
  if (row.reasoning) bits.push("reasoning");
@@ -108,161 +102,110 @@ export function selectListTheme(theme: any): SelectListTheme {
108
102
  };
109
103
  }
110
104
 
111
- const HINT =
112
- "↑↓ move • enter load • ctrl+a add • ctrl+u update • ctrl+x delete • ctrl+o options • ctrl+r reset • esc close";
105
+ const HINT = "↑↓ move · enter load · ctrl+a add · ctrl+o actions · esc close";
113
106
 
114
- export class ModelPicker extends Container implements Focusable {
115
- private search: Input;
116
- private list: SelectList;
107
+ export interface PickerOptions {
108
+ placement?: Placement;
109
+ /** Pass the same state object when reopening after an action. Updated in place. */
110
+ state?: SelectionState & { menuValue?: string };
111
+ }
112
+
113
+ const MODEL_COLUMNS: TableColumn[] = [
114
+ { key: "model", label: "MODEL" }, { key: "provider", label: "PROVIDER", width: 14 },
115
+ { key: "context", label: "CONTEXT", width: 7 }, { key: "uses", label: "USES", width: 5 },
116
+ { key: "status", label: "STATUS", width: 17 },
117
+ ];
118
+
119
+ export class ModelPicker implements Focusable {
120
+ private table: TableSelect;
121
+ private menu?: TableSelect;
117
122
  private rows: RankedModel[];
118
- private filtered: RankedModel[];
119
- private hintText: Text;
120
- private countText: Text;
121
- // Plain fields rather than constructor parameter properties, so the source
122
- // also loads under type-stripping runtimes that reject that syntax.
123
123
  private theme: any;
124
- private onChange: () => void;
124
+ private changed: () => void;
125
125
  private done: (action: PickerAction) => void;
126
-
126
+ private assignment: boolean;
127
+ private options: PickerOptions;
127
128
  private _focused = false;
128
- get focused(): boolean {
129
- return this._focused;
130
- }
131
- // Propagate focus to the Input so the hardware cursor lands correctly for IME.
129
+ get focused(): boolean { return this._focused; }
132
130
  set focused(value: boolean) {
133
131
  this._focused = value;
134
- this.search.focused = value;
132
+ this.table.focused = value && !this.menu;
133
+ if (this.menu) this.menu.focused = value;
135
134
  }
136
135
 
137
- constructor(
138
- rows: RankedModel[],
139
- theme: any,
140
- onChange: () => void,
141
- done: (action: PickerAction) => void,
142
- ) {
143
- super();
136
+ constructor(rows: RankedModel[], theme: any, changed: () => void,
137
+ done: (action: PickerAction) => void, assignment = false, options: PickerOptions = {},
138
+ title = "Better Model Select") {
139
+ this.rows = rows;
144
140
  this.theme = theme;
145
- this.onChange = onChange;
141
+ this.changed = changed;
146
142
  this.done = done;
147
- this.rows = rows;
148
- this.filtered = rows;
149
-
150
- const fg =
151
- typeof theme?.fg === "function"
152
- ? (role: string, t: string) => theme.fg(role, t)
153
- : (_role: string, t: string) => t;
154
-
155
- this.search = new Input({
156
- prompt: " search ",
157
- placeholder: "type to filter by name, id or provider…",
158
- placeholderStyle: (t: string) => fg("dim", t),
159
- });
160
- this.search.onEscape = () => this.done({ kind: "close" });
161
- this.search.onSubmit = () => this.loadSelected();
162
-
163
- this.list = this.buildList(rows);
164
-
165
- this.countText = new Text(fg("dim", this.countLabel()), 1, 0);
166
- this.hintText = new Text(fg("dim", HINT), 1, 0);
167
-
168
- this.addChild(this.search);
169
- this.addChild(this.list);
170
- this.addChild(this.countText);
171
- this.addChild(this.hintText);
172
- }
173
-
174
- /** SelectList has no public API to replace its items, so rebuild it. */
175
- private buildList(rows: RankedModel[]): SelectList {
176
- const list = new SelectList(
177
- rows.map(toSelectItem),
178
- Math.min(Math.max(rows.length, 1), 12),
179
- selectListTheme(this.theme),
180
- );
181
- list.onSelect = () => this.loadSelected();
182
- list.onCancel = () => this.done({ kind: "close" });
183
- return list;
143
+ this.assignment = assignment;
144
+ this.options = options;
145
+ this.table = new TableSelect({ title, columns: MODEL_COLUMNS,
146
+ rows: rows.map(row => ({ value: toSelectItem(row).value, label: row.label,
147
+ // IDs remain searchable but are not repeated under the display name.
148
+ description: row.modelId,
149
+ cells: { model: row.label, provider: row.provider, context: formatContext(row.contextWindow),
150
+ uses: String(row.count), status: [row.current ? "current" : "", row.default ? "default" : ""].filter(Boolean).join(" · ") },
151
+ })), placement: options.placement,
152
+ hint: assignment ? "↑↓ move · enter assign · esc back" : HINT,
153
+ }, theme, () => { this.saveState(); changed(); }, value => {
154
+ this.saveState();
155
+ if (value === undefined) done({ kind: "close" });
156
+ else this.action("load");
157
+ }, options.state);
158
+ if (options.state?.menuValue) this.openMenu();
184
159
  }
185
-
186
- private countLabel(): string {
187
- const total = this.rows.length;
188
- const shown = this.filtered.length;
189
- return shown === total ? `${total} models` : `${shown} of ${total} models`;
190
- }
191
-
192
- private currentRow(): RankedModel | null {
193
- const item = this.list.getSelectedItem();
194
- if (!item) return null;
195
- const decoded = decodeValue(item.value);
196
- if (!decoded) return null;
197
- return (
198
- this.filtered.find(
199
- (r) => r.provider === decoded.provider && r.modelId === decoded.modelId,
200
- ) ?? null
201
- );
160
+ private saveState(): void { if (this.options.state) Object.assign(this.options.state, this.table.state); }
161
+ private currentRow(): RankedModel | undefined {
162
+ const value = this.table.currentItem()?.value;
163
+ return this.rows.find(row => toSelectItem(row).value === value);
202
164
  }
203
-
204
- private loadSelected(): void {
165
+ private action(kind: PickerAction["kind"]): void {
166
+ this.saveState();
205
167
  const row = this.currentRow();
206
- if (!row) return;
207
- this.done({ kind: "load", provider: row.provider, modelId: row.modelId });
168
+ if (kind === "load" || kind === "set-default") {
169
+ if (row) this.done({ kind, provider: row.provider, modelId: row.modelId });
170
+ } else if (kind === "update" || kind === "delete") {
171
+ this.done({ kind, provider: row?.provider, modelId: row?.modelId });
172
+ } else this.done({ kind });
208
173
  }
209
-
210
- /**
211
- * Re-filter with fuzzyFilter rather than SelectList.setFilter, which only does
212
- * a prefix match on item.value and would ignore the display name. Ranking is
213
- * preserved because fuzzyFilter is stable over the already-sorted rows for
214
- * equal scores, and exact-prefix hits still surface first.
215
- */
216
- private applyFilter(): void {
217
- const query = this.search.getValue().trim();
218
- this.filtered = query
219
- ? fuzzyFilter(this.rows, query, (r) => `${r.label} ${r.modelId} ${r.provider}`)
220
- : this.rows;
221
-
222
- // Swap the list child in place; index 1 is the SelectList.
223
- const next = this.buildList(this.filtered);
224
- const idx = this.children.indexOf(this.list);
225
- if (idx >= 0) this.children[idx] = next;
226
- this.list = next;
227
-
228
- const fg =
229
- typeof this.theme?.fg === "function"
230
- ? (role: string, t: string) => this.theme.fg(role, t)
231
- : (_role: string, t: string) => t;
232
- this.countText.setText(fg("dim", this.countLabel()));
174
+ private openMenu(): void {
175
+ const row = this.currentRow();
176
+ const actions: { value: PickerAction["kind"]; label: string }[] = [
177
+ ...(row ? [{ value: "load" as const, label: "Load selected model" },
178
+ { value: "set-default" as const, label: "Set selected model as default" }] : []),
179
+ { value: "add", label: "Add model" }, { value: "update", label: "Update model" },
180
+ { value: "delete", label: "Delete model" }, { value: "defaults", label: "Model and agent defaults" },
181
+ { value: "options", label: "Options" }, { value: "reset", label: "Reset usage ranking" },
182
+ ];
183
+ this.menu = new TableSelect({ title: "Models / Actions", rows: actions, search: false, initialValue: this.options.state?.menuValue,
184
+ hint: "↑↓ move · enter choose · esc back" }, this.theme, this.changed, value => {
185
+ if (this.options.state) {
186
+ if (value === undefined) delete this.options.state.menuValue;
187
+ else this.options.state.menuValue = value;
188
+ }
189
+ this.menu = undefined;
190
+ this.focused = this._focused;
191
+ if (value !== undefined) this.action(value as PickerAction["kind"]);
192
+ this.changed();
193
+ });
194
+ this.focused = this._focused;
195
+ this.changed();
233
196
  }
234
-
235
197
  handleInput(data: string): void {
236
- if (matchesKey(data, "escape")) return this.done({ kind: "close" });
237
- if (matchesKey(data, "ctrl+a")) return this.done({ kind: "add" });
238
- if (matchesKey(data, "ctrl+o")) return this.done({ kind: "options" });
239
- if (matchesKey(data, "ctrl+r")) return this.done({ kind: "reset" });
240
-
241
- if (matchesKey(data, "ctrl+u")) {
242
- const row = this.currentRow();
243
- return this.done({ kind: "update", provider: row?.provider, modelId: row?.modelId });
244
- }
245
- if (matchesKey(data, "ctrl+x")) {
246
- const row = this.currentRow();
247
- return this.done({ kind: "delete", provider: row?.provider, modelId: row?.modelId });
198
+ if (this.menu) { this.menu.handleInput(data); return; }
199
+ const shortcuts: [string, PickerAction["kind"]][] = [
200
+ ["ctrl+a", "add"], ["ctrl+u", "update"], ["ctrl+x", "delete"], ["ctrl+d", "set-default"],
201
+ ["ctrl+g", "defaults"], ["ctrl+r", "reset"],
202
+ ];
203
+ if (matchesKey(data, "ctrl+o")) { if (!this.assignment) this.openMenu(); return; }
204
+ for (const [key, kind] of shortcuts) {
205
+ if (matchesKey(data, key as any)) { if (!this.assignment) this.action(kind); return; }
248
206
  }
249
-
250
- // Navigation and submit belong to the list; everything else is search text.
251
- if (
252
- matchesKey(data, "up") ||
253
- matchesKey(data, "down") ||
254
- matchesKey(data, "pageUp") ||
255
- matchesKey(data, "pageDown") ||
256
- matchesKey(data, "enter")
257
- ) {
258
- this.list.handleInput(data);
259
- this.onChange();
260
- return;
261
- }
262
-
263
- const before = this.search.getValue();
264
- this.search.handleInput(data);
265
- if (this.search.getValue() !== before) this.applyFilter();
266
- this.onChange();
207
+ this.table.handleInput(data);
267
208
  }
209
+ render(width: number): string[] { return (this.menu ?? this.table).render(width); }
210
+ invalidate(): void { this.table.invalidate(); this.menu?.invalidate(); }
268
211
  }
@@ -20,12 +20,14 @@ export const USAGE_PATH = (): string => join(getAgentDir(), "bms-usage.json");
20
20
  export interface BmsSettings {
21
21
  version: 1;
22
22
  writeMode: WriteMode;
23
+ resultPlacement: "above" | "below";
23
24
  disabledProviders: string[];
24
25
  }
25
26
 
26
27
  const DEFAULT_SETTINGS: BmsSettings = {
27
28
  version: 1,
28
29
  writeMode: "overlay",
30
+ resultPlacement: "below",
29
31
  disabledProviders: [],
30
32
  };
31
33
 
@@ -72,6 +74,7 @@ export function loadSettings(): BmsSettings {
72
74
  return {
73
75
  version: 1,
74
76
  writeMode: raw.writeMode === "direct" ? "direct" : "overlay",
77
+ resultPlacement: raw.resultPlacement === "above" ? "above" : "below",
75
78
  disabledProviders: Array.isArray(raw.disabledProviders)
76
79
  ? raw.disabledProviders.filter((value): value is string => typeof value === "string")
77
80
  : [],
@@ -146,7 +149,7 @@ export function mergeConfigs(base: ModelsConfig, local: ModelsConfig): ModelsCon
146
149
  export function loadConfig(ctx: {
147
150
  ui: { notify: (m: string, l: string) => void };
148
151
  }): ModelsConfig | null {
149
- const base = loadConfigFile(ctx, "models.json");
152
+ const base = loadConfigFile(ctx, "models.json", true);
150
153
  if (!base) return null;
151
154
  if (loadSettings().writeMode === "direct") return base;
152
155
  migrateLegacyOverlay();