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.
@@ -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 { DISPLAY_LEVELS, getSupportedLevels, displayToPi, piToDisplay } from "./shared.js";
8
+ import { getSupportedLevels, displayToPi } from "./shared.js";
9
9
 
10
- interface VariantEntry {
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 generateVariants(ctx: ExtensionCommandContext): VariantEntry[] {
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 variants: VariantEntry[] = [];
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
- const levels = getSupportedLevels(m);
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 variants;
44
+ return entries;
53
45
  }
54
46
 
55
- class ModelSelectorComponent extends Container implements Focusable {
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 allVariants: VariantEntry[];
59
- private filteredVariants: VariantEntry[];
54
+ private allItems: T[];
55
+ private filteredItems: T[];
60
56
  private selectedIndex = 0;
61
- private onSelectCallback: (variant: VariantEntry) => void;
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
- tui: TUI,
79
- theme: Theme,
80
- variants: VariantEntry[],
81
- onSelect: (variant: VariantEntry) => void,
82
- onCancel: () => void,
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.tui = tui;
86
- this.theme = theme;
87
- this.allVariants = variants;
88
- this.filteredVariants = variants;
89
- this.onSelectCallback = onSelect;
90
- this.onCancelCallback = onCancel;
91
-
92
- this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
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.filteredVariants[this.selectedIndex];
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(`Models (${this.allVariants.length} variants)`)),
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.filteredVariants = this.allVariants;
140
+ this.filteredItems = this.allItems;
136
141
  } else {
137
- this.filteredVariants = fuzzyFilter(this.allVariants, query, (v) => `${v.model} ${v.thinking}`);
142
+ this.filteredItems = fuzzyFilter(this.allItems, query, this.filterKey);
138
143
  }
139
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredVariants.length - 1));
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.filteredVariants.length === 0) {
147
- this.listContainer.addChild(new Text(this.theme.fg("muted", " No matching models"), 0, 0));
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.filteredVariants.length - maxVisible),
159
+ Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredItems.length - maxVisible),
155
160
  );
156
- const endIndex = Math.min(startIndex + maxVisible, this.filteredVariants.length);
161
+ const endIndex = Math.min(startIndex + maxVisible, this.filteredItems.length);
157
162
 
158
163
  for (let i = startIndex; i < endIndex; i++) {
159
- const v = this.filteredVariants[i];
160
- if (!v) continue;
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
- const modelColor = isSelected ? "accent" : "text";
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.filteredVariants.length) {
171
+ if (startIndex > 0 || endIndex < this.filteredItems.length) {
170
172
  const scrollInfo = this.theme.fg(
171
173
  "dim",
172
- ` (${this.selectedIndex + 1}/${this.filteredVariants.length})`,
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.filteredVariants.length === 0) return;
182
- this.selectedIndex = this.selectedIndex === 0 ? this.filteredVariants.length - 1 : this.selectedIndex - 1;
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.filteredVariants.length === 0) return;
188
- this.selectedIndex = this.selectedIndex === this.filteredVariants.length - 1 ? 0 : this.selectedIndex + 1;
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.filteredVariants[this.selectedIndex];
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 variants = generateVariants(ctx);
275
+ const models = getEnabledModels(ctx);
218
276
 
219
- if (variants.length === 0) {
277
+ if (models.length === 0) {
220
278
  ctx.ui.notify("No enabled models found", "error");
221
279
  return;
222
280
  }
223
281
 
224
- const result = await ctx.ui.custom<VariantEntry | null>((tui, theme, _kb, done) => {
225
- let selected: VariantEntry | null = null;
226
-
227
- const selector = new ModelSelectorComponent(
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 ${result.provider}/${result.model} not found`, "error");
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(result.thinking) as any);
259
- ctx.ui.notify(`✓ ${result.model} → ${result.thinking}`, "info");
304
+ pi.setThinkingLevel(displayToPi(chosenLevel) as any);
305
+ ctx.ui.notify(`✓ ${chosenModel.model} → ${chosenLevel}`, "info");
260
306
  },
261
307
  });
262
308
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-model-control",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Model and thinking level controls for pi — /variants and /thinking commands",
5
5
  "type": "module",
6
6
  "files": [