pi-better-model-select 0.2.0 → 0.3.1
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 +63 -0
- package/README.md +17 -7
- package/docs/configuration.md +8 -2
- package/docs/demo/showcase.gif +0 -0
- package/extensions/bms.ts +62 -50
- package/extensions/lib/defaults.ts +146 -52
- package/extensions/lib/effort-editor.ts +35 -20
- package/extensions/lib/flows.ts +126 -496
- package/extensions/lib/picker-dialog.ts +11 -19
- package/extensions/lib/picker.ts +90 -162
- package/extensions/lib/store.ts +3 -0
- package/extensions/lib/tui.ts +320 -0
- package/package.json +3 -1
|
@@ -1,21 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { ModelPicker, type PickerAction, type RankedModel } from "./picker.ts";
|
|
1
|
+
import { ModelPicker, type PickerAction, type PickerOptions, type RankedModel } from "./picker.ts";
|
|
2
|
+
import { resultPlacement } from "./tui.ts";
|
|
4
3
|
|
|
5
|
-
export async function pickerDialog(
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
container.addChild(new DynamicBorder(accent));
|
|
15
|
-
return {
|
|
16
|
-
render: (width: number) => container.render(width),
|
|
17
|
-
invalidate: () => container.invalidate(),
|
|
18
|
-
handleInput: (data: string) => picker.handleInput(data),
|
|
19
|
-
};
|
|
20
|
-
});
|
|
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));
|
|
21
13
|
}
|
package/extensions/lib/picker.ts
CHANGED
|
@@ -1,20 +1,8 @@
|
|
|
1
|
-
// The /bms picker
|
|
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
|
-
|
|
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 =
|
|
@@ -114,171 +102,111 @@ export function selectListTheme(theme: any): SelectListTheme {
|
|
|
114
102
|
};
|
|
115
103
|
}
|
|
116
104
|
|
|
117
|
-
|
|
118
|
-
|
|
105
|
+
// Keep the default shortcut ahead of secondary actions so narrow terminals show it.
|
|
106
|
+
const HINT = "↑↓ move · enter load · ctrl+d set default · ctrl+o actions · esc close";
|
|
119
107
|
|
|
120
|
-
export
|
|
121
|
-
|
|
122
|
-
|
|
108
|
+
export interface PickerOptions {
|
|
109
|
+
placement?: Placement;
|
|
110
|
+
/** Pass the same state object when reopening after an action. Updated in place. */
|
|
111
|
+
state?: SelectionState & { menuValue?: string };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const MODEL_COLUMNS: TableColumn[] = [
|
|
115
|
+
{ key: "model", label: "MODEL" }, { key: "provider", label: "PROVIDER", width: 14 },
|
|
116
|
+
{ key: "context", label: "CONTEXT", width: 7 }, { key: "uses", label: "USES", width: 5 },
|
|
117
|
+
{ key: "status", label: "STATUS", width: 17 },
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
export class ModelPicker implements Focusable {
|
|
121
|
+
private table: TableSelect;
|
|
122
|
+
private menu?: TableSelect;
|
|
123
123
|
private rows: RankedModel[];
|
|
124
|
-
private filtered: RankedModel[];
|
|
125
|
-
private hintText: Text;
|
|
126
|
-
private countText: Text;
|
|
127
|
-
// Plain fields rather than constructor parameter properties, so the source
|
|
128
|
-
// also loads under type-stripping runtimes that reject that syntax.
|
|
129
124
|
private theme: any;
|
|
130
|
-
private
|
|
125
|
+
private changed: () => void;
|
|
131
126
|
private done: (action: PickerAction) => void;
|
|
132
127
|
private assignment: boolean;
|
|
133
|
-
|
|
128
|
+
private options: PickerOptions;
|
|
134
129
|
private _focused = false;
|
|
135
|
-
get focused(): boolean {
|
|
136
|
-
return this._focused;
|
|
137
|
-
}
|
|
138
|
-
// Propagate focus to the Input so the hardware cursor lands correctly for IME.
|
|
130
|
+
get focused(): boolean { return this._focused; }
|
|
139
131
|
set focused(value: boolean) {
|
|
140
132
|
this._focused = value;
|
|
141
|
-
this.
|
|
133
|
+
this.table.focused = value && !this.menu;
|
|
134
|
+
if (this.menu) this.menu.focused = value;
|
|
142
135
|
}
|
|
143
136
|
|
|
144
|
-
constructor(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
done: (action: PickerAction) => void,
|
|
149
|
-
assignment = false,
|
|
150
|
-
) {
|
|
151
|
-
super();
|
|
137
|
+
constructor(rows: RankedModel[], theme: any, changed: () => void,
|
|
138
|
+
done: (action: PickerAction) => void, assignment = false, options: PickerOptions = {},
|
|
139
|
+
title = "Better Model Select") {
|
|
140
|
+
this.rows = rows;
|
|
152
141
|
this.theme = theme;
|
|
153
|
-
this.
|
|
142
|
+
this.changed = changed;
|
|
154
143
|
this.done = done;
|
|
155
144
|
this.assignment = assignment;
|
|
156
|
-
this.
|
|
157
|
-
this.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
this.list = this.buildList(rows);
|
|
173
|
-
|
|
174
|
-
this.countText = new Text(fg("dim", this.countLabel()), 1, 0);
|
|
175
|
-
this.hintText = new Text(fg("dim", assignment ? "↑↓ move • enter assign • esc back" : HINT), 1, 0);
|
|
176
|
-
|
|
177
|
-
this.addChild(this.search);
|
|
178
|
-
this.addChild(this.list);
|
|
179
|
-
this.addChild(this.countText);
|
|
180
|
-
this.addChild(this.hintText);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** SelectList has no public API to replace its items, so rebuild it. */
|
|
184
|
-
private buildList(rows: RankedModel[]): SelectList {
|
|
185
|
-
const list = new SelectList(
|
|
186
|
-
rows.map(toSelectItem),
|
|
187
|
-
Math.min(Math.max(rows.length, 1), 12),
|
|
188
|
-
selectListTheme(this.theme),
|
|
189
|
-
);
|
|
190
|
-
list.onSelect = () => this.loadSelected();
|
|
191
|
-
list.onCancel = () => this.done({ kind: "close" });
|
|
192
|
-
return list;
|
|
145
|
+
this.options = options;
|
|
146
|
+
this.table = new TableSelect({ title, columns: MODEL_COLUMNS,
|
|
147
|
+
rows: rows.map(row => ({ value: toSelectItem(row).value, label: row.label,
|
|
148
|
+
// IDs remain searchable but are not repeated under the display name.
|
|
149
|
+
description: row.modelId,
|
|
150
|
+
cells: { model: row.label, provider: row.provider, context: formatContext(row.contextWindow),
|
|
151
|
+
uses: String(row.count), status: [row.current ? "current" : "", row.default ? "default" : ""].filter(Boolean).join(" · ") },
|
|
152
|
+
})), placement: options.placement,
|
|
153
|
+
hint: assignment ? "↑↓ move · enter assign · esc back" : HINT,
|
|
154
|
+
}, theme, () => { this.saveState(); changed(); }, value => {
|
|
155
|
+
this.saveState();
|
|
156
|
+
if (value === undefined) done({ kind: "close" });
|
|
157
|
+
else this.action("load");
|
|
158
|
+
}, options.state);
|
|
159
|
+
if (options.state?.menuValue) this.openMenu();
|
|
193
160
|
}
|
|
194
|
-
|
|
195
|
-
private
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
return shown === total ? `${total} models` : `${shown} of ${total} models`;
|
|
161
|
+
private saveState(): void { if (this.options.state) Object.assign(this.options.state, this.table.state); }
|
|
162
|
+
private currentRow(): RankedModel | undefined {
|
|
163
|
+
const value = this.table.currentItem()?.value;
|
|
164
|
+
return this.rows.find(row => toSelectItem(row).value === value);
|
|
199
165
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const item = this.list.getSelectedItem();
|
|
203
|
-
if (!item) return null;
|
|
204
|
-
const decoded = decodeValue(item.value);
|
|
205
|
-
if (!decoded) return null;
|
|
206
|
-
return (
|
|
207
|
-
this.filtered.find(
|
|
208
|
-
(r) => r.provider === decoded.provider && r.modelId === decoded.modelId,
|
|
209
|
-
) ?? null
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
private loadSelected(): void {
|
|
166
|
+
private action(kind: PickerAction["kind"]): void {
|
|
167
|
+
this.saveState();
|
|
214
168
|
const row = this.currentRow();
|
|
215
|
-
if (
|
|
216
|
-
|
|
169
|
+
if (kind === "load" || kind === "set-default") {
|
|
170
|
+
if (row) this.done({ kind, provider: row.provider, modelId: row.modelId });
|
|
171
|
+
} else if (kind === "update" || kind === "delete") {
|
|
172
|
+
this.done({ kind, provider: row?.provider, modelId: row?.modelId });
|
|
173
|
+
} else this.done({ kind });
|
|
217
174
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
this.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
: (_role: string, t: string) => t;
|
|
241
|
-
this.countText.setText(fg("dim", this.countLabel()));
|
|
175
|
+
private openMenu(): void {
|
|
176
|
+
const row = this.currentRow();
|
|
177
|
+
const actions: { value: PickerAction["kind"]; label: string }[] = [
|
|
178
|
+
...(row ? [{ value: "load" as const, label: "Load selected model" },
|
|
179
|
+
{ value: "set-default" as const, label: "Set selected model as default" }] : []),
|
|
180
|
+
{ value: "add", label: "Add model" }, { value: "update", label: "Update model" },
|
|
181
|
+
{ value: "delete", label: "Delete model" }, { value: "defaults", label: "Model and agent defaults" },
|
|
182
|
+
{ value: "options", label: "Options" }, { value: "reset", label: "Reset usage ranking" },
|
|
183
|
+
];
|
|
184
|
+
this.menu = new TableSelect({ title: "Models / Actions", rows: actions, search: false, initialValue: this.options.state?.menuValue,
|
|
185
|
+
hint: "↑↓ move · enter choose · esc back" }, this.theme, this.changed, value => {
|
|
186
|
+
if (this.options.state) {
|
|
187
|
+
if (value === undefined) delete this.options.state.menuValue;
|
|
188
|
+
else this.options.state.menuValue = value;
|
|
189
|
+
}
|
|
190
|
+
this.menu = undefined;
|
|
191
|
+
this.focused = this._focused;
|
|
192
|
+
if (value !== undefined) this.action(value as PickerAction["kind"]);
|
|
193
|
+
this.changed();
|
|
194
|
+
});
|
|
195
|
+
this.focused = this._focused;
|
|
196
|
+
this.changed();
|
|
242
197
|
}
|
|
243
|
-
|
|
244
198
|
handleInput(data: string): void {
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (matchesKey(data, "ctrl+a")) return this.done({ kind: "add" });
|
|
254
|
-
if (matchesKey(data, "ctrl+o")) return this.done({ kind: "options" });
|
|
255
|
-
if (matchesKey(data, "ctrl+r")) return this.done({ kind: "reset" });
|
|
256
|
-
|
|
257
|
-
if (matchesKey(data, "ctrl+u")) {
|
|
258
|
-
const row = this.currentRow();
|
|
259
|
-
return this.done({ kind: "update", provider: row?.provider, modelId: row?.modelId });
|
|
260
|
-
}
|
|
261
|
-
if (matchesKey(data, "ctrl+x")) {
|
|
262
|
-
const row = this.currentRow();
|
|
263
|
-
return this.done({ kind: "delete", provider: row?.provider, modelId: row?.modelId });
|
|
199
|
+
if (this.menu) { this.menu.handleInput(data); return; }
|
|
200
|
+
const shortcuts: [string, PickerAction["kind"]][] = [
|
|
201
|
+
["ctrl+a", "add"], ["ctrl+u", "update"], ["ctrl+x", "delete"], ["ctrl+d", "set-default"],
|
|
202
|
+
["ctrl+g", "defaults"], ["ctrl+r", "reset"],
|
|
203
|
+
];
|
|
204
|
+
if (matchesKey(data, "ctrl+o")) { if (!this.assignment) this.openMenu(); return; }
|
|
205
|
+
for (const [key, kind] of shortcuts) {
|
|
206
|
+
if (matchesKey(data, key as any)) { if (!this.assignment) this.action(kind); return; }
|
|
264
207
|
}
|
|
265
|
-
|
|
266
|
-
// Navigation and submit belong to the list; everything else is search text.
|
|
267
|
-
if (
|
|
268
|
-
matchesKey(data, "up") ||
|
|
269
|
-
matchesKey(data, "down") ||
|
|
270
|
-
matchesKey(data, "pageUp") ||
|
|
271
|
-
matchesKey(data, "pageDown") ||
|
|
272
|
-
matchesKey(data, "enter")
|
|
273
|
-
) {
|
|
274
|
-
this.list.handleInput(data);
|
|
275
|
-
this.onChange();
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const before = this.search.getValue();
|
|
280
|
-
this.search.handleInput(data);
|
|
281
|
-
if (this.search.getValue() !== before) this.applyFilter();
|
|
282
|
-
this.onChange();
|
|
208
|
+
this.table.handleInput(data);
|
|
283
209
|
}
|
|
210
|
+
render(width: number): string[] { return (this.menu ?? this.table).render(width); }
|
|
211
|
+
invalidate(): void { this.table.invalidate(); this.menu?.invalidate(); }
|
|
284
212
|
}
|
package/extensions/lib/store.ts
CHANGED
|
@@ -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
|
: [],
|