pi-model-control 0.1.9 → 0.1.10
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/variants.ts +137 -91
- package/package.json +1 -1
package/extensions/variants.ts
CHANGED
|
@@ -5,12 +5,11 @@ import type { Focusable } from "@earendil-works/pi-tui";
|
|
|
5
5
|
import { readFileSync, existsSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
|
-
import {
|
|
8
|
+
import { getSupportedLevels, displayToPi } from "./shared.js";
|
|
9
9
|
|
|
10
|
-
interface
|
|
10
|
+
interface ModelEntry {
|
|
11
11
|
provider: string;
|
|
12
12
|
model: string;
|
|
13
|
-
thinking: string;
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
function loadEnabledModels(): Set<string> | null {
|
|
@@ -26,11 +25,11 @@ function loadEnabledModels(): Set<string> | null {
|
|
|
26
25
|
return null;
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
function
|
|
28
|
+
function getEnabledModels(ctx: ExtensionCommandContext): ModelEntry[] {
|
|
30
29
|
const allModels = ctx.modelRegistry.getAll();
|
|
31
30
|
const enabledSet = loadEnabledModels();
|
|
32
31
|
const seen = new Set<string>();
|
|
33
|
-
const
|
|
32
|
+
const entries: ModelEntry[] = [];
|
|
34
33
|
|
|
35
34
|
for (const m of allModels) {
|
|
36
35
|
const key = `${m.provider}/${m.id}`;
|
|
@@ -39,31 +38,31 @@ function generateVariants(ctx: ExtensionCommandContext): VariantEntry[] {
|
|
|
39
38
|
|
|
40
39
|
if (enabledSet && !enabledSet.has(key)) continue;
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
for (const t of levels) {
|
|
44
|
-
variants.push({
|
|
45
|
-
provider: m.provider,
|
|
46
|
-
model: m.id,
|
|
47
|
-
thinking: t,
|
|
48
|
-
});
|
|
49
|
-
}
|
|
41
|
+
entries.push({ provider: m.provider, model: m.id });
|
|
50
42
|
}
|
|
51
43
|
|
|
52
|
-
return
|
|
44
|
+
return entries;
|
|
53
45
|
}
|
|
54
46
|
|
|
55
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Универсальный searchable-список для TUI.
|
|
49
|
+
* Рендерит `renderLine` для каждого элемента, фильтрует по `filterKey`.
|
|
50
|
+
*/
|
|
51
|
+
class SearchableSelector<T> extends Container implements Focusable {
|
|
56
52
|
private searchInput: Input;
|
|
57
53
|
private listContainer: Container;
|
|
58
|
-
private
|
|
59
|
-
private
|
|
54
|
+
private allItems: T[];
|
|
55
|
+
private filteredItems: T[];
|
|
60
56
|
private selectedIndex = 0;
|
|
61
|
-
private onSelectCallback: (
|
|
57
|
+
private onSelectCallback: (item: T) => void;
|
|
62
58
|
private onCancelCallback: () => void;
|
|
63
|
-
private tui: TUI;
|
|
64
59
|
private theme: Theme;
|
|
65
60
|
private headerText: Text;
|
|
66
61
|
private hintText: Text;
|
|
62
|
+
private headerLabel: string;
|
|
63
|
+
private filterKey: (item: T) => string;
|
|
64
|
+
private renderLine: (item: T, isSelected: boolean) => string;
|
|
65
|
+
private emptyLabel: string;
|
|
67
66
|
|
|
68
67
|
private _focused = false;
|
|
69
68
|
get focused(): boolean {
|
|
@@ -74,22 +73,28 @@ class ModelSelectorComponent extends Container implements Focusable {
|
|
|
74
73
|
this.searchInput.focused = value;
|
|
75
74
|
}
|
|
76
75
|
|
|
77
|
-
constructor(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
76
|
+
constructor(opts: {
|
|
77
|
+
theme: Theme;
|
|
78
|
+
items: T[];
|
|
79
|
+
headerLabel: string;
|
|
80
|
+
emptyLabel: string;
|
|
81
|
+
filterKey: (item: T) => string;
|
|
82
|
+
renderLine: (item: T, isSelected: boolean) => string;
|
|
83
|
+
onSelect: (item: T) => void;
|
|
84
|
+
onCancel: () => void;
|
|
85
|
+
}) {
|
|
84
86
|
super();
|
|
85
|
-
this.
|
|
86
|
-
this.
|
|
87
|
-
this.
|
|
88
|
-
this.
|
|
89
|
-
this.
|
|
90
|
-
this.
|
|
91
|
-
|
|
92
|
-
this.
|
|
87
|
+
this.theme = opts.theme;
|
|
88
|
+
this.allItems = opts.items;
|
|
89
|
+
this.filteredItems = opts.items;
|
|
90
|
+
this.headerLabel = opts.headerLabel;
|
|
91
|
+
this.emptyLabel = opts.emptyLabel;
|
|
92
|
+
this.filterKey = opts.filterKey;
|
|
93
|
+
this.renderLine = opts.renderLine;
|
|
94
|
+
this.onSelectCallback = opts.onSelect;
|
|
95
|
+
this.onCancelCallback = opts.onCancel;
|
|
96
|
+
|
|
97
|
+
this.addChild(new DynamicBorder((s: string) => opts.theme.fg("accent", s)));
|
|
93
98
|
this.addChild(new Spacer(1));
|
|
94
99
|
|
|
95
100
|
this.headerText = new Text("", 1, 0);
|
|
@@ -98,7 +103,7 @@ class ModelSelectorComponent extends Container implements Focusable {
|
|
|
98
103
|
|
|
99
104
|
this.searchInput = new Input();
|
|
100
105
|
this.searchInput.onSubmit = () => {
|
|
101
|
-
const selected = this.
|
|
106
|
+
const selected = this.filteredItems[this.selectedIndex];
|
|
102
107
|
if (selected) this.onSelectCallback(selected);
|
|
103
108
|
};
|
|
104
109
|
this.addChild(this.searchInput);
|
|
@@ -111,7 +116,7 @@ class ModelSelectorComponent extends Container implements Focusable {
|
|
|
111
116
|
this.hintText = new Text("", 1, 0);
|
|
112
117
|
this.addChild(this.hintText);
|
|
113
118
|
this.addChild(new Spacer(1));
|
|
114
|
-
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
119
|
+
this.addChild(new DynamicBorder((s: string) => opts.theme.fg("accent", s)));
|
|
115
120
|
|
|
116
121
|
this.updateHeader();
|
|
117
122
|
this.updateHints();
|
|
@@ -120,7 +125,7 @@ class ModelSelectorComponent extends Container implements Focusable {
|
|
|
120
125
|
|
|
121
126
|
private updateHeader(): void {
|
|
122
127
|
this.headerText.setText(
|
|
123
|
-
this.theme.fg("accent", this.theme.bold(
|
|
128
|
+
this.theme.fg("accent", this.theme.bold(`${this.headerLabel} (${this.allItems.length})`)),
|
|
124
129
|
);
|
|
125
130
|
}
|
|
126
131
|
|
|
@@ -132,44 +137,41 @@ class ModelSelectorComponent extends Container implements Focusable {
|
|
|
132
137
|
|
|
133
138
|
private applyFilter(query: string): void {
|
|
134
139
|
if (!query.trim()) {
|
|
135
|
-
this.
|
|
140
|
+
this.filteredItems = this.allItems;
|
|
136
141
|
} else {
|
|
137
|
-
this.
|
|
142
|
+
this.filteredItems = fuzzyFilter(this.allItems, query, this.filterKey);
|
|
138
143
|
}
|
|
139
|
-
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.
|
|
144
|
+
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
|
140
145
|
this.updateList();
|
|
141
146
|
}
|
|
142
147
|
|
|
143
148
|
private updateList(): void {
|
|
144
149
|
this.listContainer.clear();
|
|
145
150
|
|
|
146
|
-
if (this.
|
|
147
|
-
this.listContainer.addChild(new Text(this.theme.fg("muted",
|
|
151
|
+
if (this.filteredItems.length === 0) {
|
|
152
|
+
this.listContainer.addChild(new Text(this.theme.fg("muted", ` ${this.emptyLabel}`), 0, 0));
|
|
148
153
|
return;
|
|
149
154
|
}
|
|
150
155
|
|
|
151
156
|
const maxVisible = 12;
|
|
152
157
|
const startIndex = Math.max(
|
|
153
158
|
0,
|
|
154
|
-
Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.
|
|
159
|
+
Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredItems.length - maxVisible),
|
|
155
160
|
);
|
|
156
|
-
const endIndex = Math.min(startIndex + maxVisible, this.
|
|
161
|
+
const endIndex = Math.min(startIndex + maxVisible, this.filteredItems.length);
|
|
157
162
|
|
|
158
163
|
for (let i = startIndex; i < endIndex; i++) {
|
|
159
|
-
const
|
|
160
|
-
if (!
|
|
164
|
+
const item = this.filteredItems[i];
|
|
165
|
+
if (!item) continue;
|
|
161
166
|
const isSelected = i === this.selectedIndex;
|
|
162
167
|
const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
|
|
163
|
-
|
|
164
|
-
const thinkingColor = isSelected ? "accent" : "muted";
|
|
165
|
-
const line = `${prefix}${this.theme.fg(modelColor, v.model)} ${this.theme.fg(thinkingColor, `(${v.thinking})`)}`;
|
|
166
|
-
this.listContainer.addChild(new Text(line, 0, 0));
|
|
168
|
+
this.listContainer.addChild(new Text(`${prefix}${this.renderLine(item, isSelected)}`, 0, 0));
|
|
167
169
|
}
|
|
168
170
|
|
|
169
|
-
if (startIndex > 0 || endIndex < this.
|
|
171
|
+
if (startIndex > 0 || endIndex < this.filteredItems.length) {
|
|
170
172
|
const scrollInfo = this.theme.fg(
|
|
171
173
|
"dim",
|
|
172
|
-
` (${this.selectedIndex + 1}/${this.
|
|
174
|
+
` (${this.selectedIndex + 1}/${this.filteredItems.length})`,
|
|
173
175
|
);
|
|
174
176
|
this.listContainer.addChild(new Text(scrollInfo, 0, 0));
|
|
175
177
|
}
|
|
@@ -178,19 +180,19 @@ class ModelSelectorComponent extends Container implements Focusable {
|
|
|
178
180
|
handleInput(keyData: string): void {
|
|
179
181
|
const kb = getKeybindings();
|
|
180
182
|
if (kb.matches(keyData, "tui.select.up")) {
|
|
181
|
-
if (this.
|
|
182
|
-
this.selectedIndex = this.selectedIndex === 0 ? this.
|
|
183
|
+
if (this.filteredItems.length === 0) return;
|
|
184
|
+
this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;
|
|
183
185
|
this.updateList();
|
|
184
186
|
return;
|
|
185
187
|
}
|
|
186
188
|
if (kb.matches(keyData, "tui.select.down")) {
|
|
187
|
-
if (this.
|
|
188
|
-
this.selectedIndex = this.selectedIndex === this.
|
|
189
|
+
if (this.filteredItems.length === 0) return;
|
|
190
|
+
this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;
|
|
189
191
|
this.updateList();
|
|
190
192
|
return;
|
|
191
193
|
}
|
|
192
194
|
if (kb.matches(keyData, "tui.select.confirm")) {
|
|
193
|
-
const selected = this.
|
|
195
|
+
const selected = this.filteredItems[this.selectedIndex];
|
|
194
196
|
if (selected) this.onSelectCallback(selected);
|
|
195
197
|
return;
|
|
196
198
|
}
|
|
@@ -210,53 +212,97 @@ class ModelSelectorComponent extends Container implements Focusable {
|
|
|
210
212
|
}
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
async function pickModel(
|
|
216
|
+
ctx: ExtensionCommandContext,
|
|
217
|
+
models: ModelEntry[],
|
|
218
|
+
): Promise<ModelEntry | null> {
|
|
219
|
+
return ctx.ui.custom<ModelEntry | null>((tui: TUI, theme, _kb, done) => {
|
|
220
|
+
const selector = new SearchableSelector<ModelEntry>({
|
|
221
|
+
theme,
|
|
222
|
+
items: models,
|
|
223
|
+
headerLabel: "Models",
|
|
224
|
+
emptyLabel: "No matching models",
|
|
225
|
+
filterKey: (m) => `${m.provider} ${m.model}`,
|
|
226
|
+
renderLine: (m, isSelected) => {
|
|
227
|
+
const color = isSelected ? "accent" : "text";
|
|
228
|
+
const dim = isSelected ? "accent" : "muted";
|
|
229
|
+
return `${theme.fg(color, m.model)} ${theme.fg(dim, `(${m.provider})`)}`;
|
|
230
|
+
},
|
|
231
|
+
onSelect: (m) => done(m),
|
|
232
|
+
onCancel: () => done(null),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
get focused() { return true; },
|
|
237
|
+
set focused(value: boolean) { selector.focused = value; },
|
|
238
|
+
render(width: number) { return selector.render(width); },
|
|
239
|
+
invalidate() { selector.invalidate(); },
|
|
240
|
+
handleInput(data: string) { selector.handleInput(data); },
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function pickLevel(
|
|
246
|
+
ctx: ExtensionCommandContext,
|
|
247
|
+
levels: string[],
|
|
248
|
+
): Promise<string | null> {
|
|
249
|
+
return ctx.ui.custom<string | null>((tui: TUI, theme, _kb, done) => {
|
|
250
|
+
const selector = new SearchableSelector<string>({
|
|
251
|
+
theme,
|
|
252
|
+
items: levels,
|
|
253
|
+
headerLabel: "Thinking level",
|
|
254
|
+
emptyLabel: "No matching levels",
|
|
255
|
+
filterKey: (l) => l,
|
|
256
|
+
renderLine: (l, isSelected) => theme.fg(isSelected ? "accent" : "text", l),
|
|
257
|
+
onSelect: (l) => done(l),
|
|
258
|
+
onCancel: () => done(null),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
get focused() { return true; },
|
|
263
|
+
set focused(value: boolean) { selector.focused = value; },
|
|
264
|
+
render(width: number) { return selector.render(width); },
|
|
265
|
+
invalidate() { selector.invalidate(); },
|
|
266
|
+
handleInput(data: string) { selector.handleInput(data); },
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
213
271
|
export default function (pi: ExtensionAPI) {
|
|
214
272
|
pi.registerCommand("variants", {
|
|
215
|
-
description: "Switch model + thinking level (searchable)",
|
|
273
|
+
description: "Switch model + thinking level (searchable, two-step)",
|
|
216
274
|
async handler(_args: string, ctx: ExtensionCommandContext) {
|
|
217
|
-
const
|
|
275
|
+
const models = getEnabledModels(ctx);
|
|
218
276
|
|
|
219
|
-
if (
|
|
277
|
+
if (models.length === 0) {
|
|
220
278
|
ctx.ui.notify("No enabled models found", "error");
|
|
221
279
|
return;
|
|
222
280
|
}
|
|
223
281
|
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
tui,
|
|
229
|
-
theme,
|
|
230
|
-
variants,
|
|
231
|
-
(v) => {
|
|
232
|
-
selected = v;
|
|
233
|
-
done(v);
|
|
234
|
-
},
|
|
235
|
-
() => done(null),
|
|
236
|
-
);
|
|
237
|
-
|
|
238
|
-
return {
|
|
239
|
-
get focused() { return true; },
|
|
240
|
-
set focused(value: boolean) {
|
|
241
|
-
selector.focused = value;
|
|
242
|
-
},
|
|
243
|
-
render(width: number) { return selector.render(width); },
|
|
244
|
-
invalidate() { selector.invalidate(); },
|
|
245
|
-
handleInput(data: string) { selector.handleInput(data); },
|
|
246
|
-
};
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
if (!result) return;
|
|
250
|
-
|
|
251
|
-
const model = ctx.modelRegistry.find(result.provider, result.model);
|
|
282
|
+
const chosenModel = await pickModel(ctx, models);
|
|
283
|
+
if (!chosenModel) return;
|
|
284
|
+
|
|
285
|
+
const model = ctx.modelRegistry.find(chosenModel.provider, chosenModel.model);
|
|
252
286
|
if (!model) {
|
|
253
|
-
ctx.ui.notify(`Model ${
|
|
287
|
+
ctx.ui.notify(`Model ${chosenModel.provider}/${chosenModel.model} not found`, "error");
|
|
254
288
|
return;
|
|
255
289
|
}
|
|
256
290
|
|
|
291
|
+
const levels = getSupportedLevels(model);
|
|
292
|
+
|
|
293
|
+
let chosenLevel: string;
|
|
294
|
+
if (levels.length <= 1) {
|
|
295
|
+
// У модели нет выбора уровня (или только "default") — не дёргаем UI зря
|
|
296
|
+
chosenLevel = levels[0] ?? "default";
|
|
297
|
+
} else {
|
|
298
|
+
const picked = await pickLevel(ctx, levels);
|
|
299
|
+
if (picked === null) return;
|
|
300
|
+
chosenLevel = picked;
|
|
301
|
+
}
|
|
302
|
+
|
|
257
303
|
await pi.setModel(model);
|
|
258
|
-
pi.setThinkingLevel(displayToPi(
|
|
259
|
-
ctx.ui.notify(`✓ ${
|
|
304
|
+
pi.setThinkingLevel(displayToPi(chosenLevel) as any);
|
|
305
|
+
ctx.ui.notify(`✓ ${chosenModel.model} → ${chosenLevel}`, "info");
|
|
260
306
|
},
|
|
261
307
|
});
|
|
262
308
|
}
|