@pi-unipi/image 2.2.0 → 2.2.2
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/README.md +14 -4
- package/package.json +1 -1
- package/skills/image/SKILL.md +4 -1
- package/src/models.ts +168 -7
- package/src/tools.ts +5 -3
- package/src/tui/model-selector.ts +95 -6
- package/src/tui/settings-dialog.ts +41 -21
package/README.md
CHANGED
|
@@ -22,10 +22,20 @@ image_generate(prompt: "A cutaway diagram of a submarine, technical illustration
|
|
|
22
22
|
image_generate(prompt: "...", model: "flux.2-pro")
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Models come from
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
Models come from two places, merged into one list:
|
|
26
|
+
|
|
27
|
+
- pi-ai's built-in image catalog — 34 models including FLUX.2, Gemini 3 Pro
|
|
28
|
+
Image, GPT-5 Image, Recraft and Riverflow — served through **OpenRouter**
|
|
29
|
+
([get a key](https://openrouter.ai/keys)).
|
|
30
|
+
- **Any provider registered by another extension.** Third-party providers
|
|
31
|
+
publish no image metadata, so these are detected by name; a real registry
|
|
32
|
+
with 393 models contributed 12 generators (FLUX, Nano Banana, Ideogram,
|
|
33
|
+
Recraft, Seedream, Stable Diffusion) alongside the built-ins.
|
|
34
|
+
|
|
35
|
+
Because that detection is heuristic, the picker has a **custom entry** — press
|
|
36
|
+
`c` in `/unipi:image-settings` and type any `provider/model-id`. A
|
|
37
|
+
well-formed reference is always accepted, even when the catalog has never
|
|
38
|
+
heard of it, so no model is ever unreachable.
|
|
29
39
|
|
|
30
40
|
The `model` parameter is fuzzy-matched, so `flux`, `recraft` and
|
|
31
41
|
`gemini-3-pro` all work. Omit it to use the model chosen in
|
package/package.json
CHANGED
package/skills/image/SKILL.md
CHANGED
|
@@ -63,8 +63,11 @@ magic numbers, so a misnamed extension still works.
|
|
|
63
63
|
|
|
64
64
|
`/unipi:image-settings` configures both tools:
|
|
65
65
|
|
|
66
|
-
- Generation model (picker over the image
|
|
66
|
+
- Generation model (picker over the built-in catalog plus image models from
|
|
67
|
+
any provider registered by another extension)
|
|
67
68
|
- Recognition model (picker over vision-capable models only)
|
|
69
|
+
- Either picker accepts a **custom** `provider/model-id` — press `c` — for a
|
|
70
|
+
model the catalog does not list
|
|
68
71
|
- Enable/disable either tool
|
|
69
72
|
- Output directory and whether to save to disk
|
|
70
73
|
- The recognition system prompt
|
package/src/models.ts
CHANGED
|
@@ -72,10 +72,20 @@ let imagesModelsAttempted = false;
|
|
|
72
72
|
/**
|
|
73
73
|
* Load pi-ai's built-in images collection.
|
|
74
74
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
75
|
+
* ⚠️ Do NOT reach for `getImageModels`/`getImageProviders`/`generateImages`:
|
|
76
|
+
*
|
|
77
|
+
* - They are NOT re-exported from the pi-ai package root. Importing
|
|
78
|
+
* `@earendil-works/pi-ai` and calling `getImageModels("openrouter")`
|
|
79
|
+
* returns an EMPTY ARRAY rather than throwing, so the mistake looks like
|
|
80
|
+
* "no models are installed" and costs a long debugging session.
|
|
81
|
+
* - The file that defines them, `dist/image-models.js`, is not a permitted
|
|
82
|
+
* subpath in pi-ai's `exports` map, so importing it directly throws
|
|
83
|
+
* ERR_PACKAGE_PATH_NOT_EXPORTED.
|
|
84
|
+
*
|
|
85
|
+
* The supported entry point is `@earendil-works/pi-ai/providers/all` →
|
|
86
|
+
* `builtinImagesModels()`, which returns the catalog AND resolves auth.
|
|
87
|
+
* A failure is not fatal: it degrades to null and callers report that no
|
|
88
|
+
* models are available.
|
|
79
89
|
*/
|
|
80
90
|
export async function getImagesModels(): Promise<ImagesModelsLike | null> {
|
|
81
91
|
if (cachedImagesModels || imagesModelsAttempted) return cachedImagesModels;
|
|
@@ -106,6 +116,111 @@ export async function listImageGenModels(): Promise<ImageGenModel[]> {
|
|
|
106
116
|
}
|
|
107
117
|
}
|
|
108
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Heuristic: does a chat-registry model look like an image generator?
|
|
121
|
+
*
|
|
122
|
+
* Third-party providers registered by other extensions (pi-omniroute-bridge,
|
|
123
|
+
* for example) surface text-to-image endpoints as ordinary chat models. They
|
|
124
|
+
* declare no `output` modality at all, so a strict `output.includes("image")`
|
|
125
|
+
* check finds nothing and the user sees only pi-ai's built-in OpenRouter
|
|
126
|
+
* catalog. We therefore accept an explicit image output when present, and
|
|
127
|
+
* otherwise fall back to well-known generator naming.
|
|
128
|
+
*/
|
|
129
|
+
const GENERATOR_NAME_HINTS = [
|
|
130
|
+
"text-to-image",
|
|
131
|
+
"flux",
|
|
132
|
+
"dall-e",
|
|
133
|
+
"dalle",
|
|
134
|
+
"imagen",
|
|
135
|
+
"recraft",
|
|
136
|
+
"seedream",
|
|
137
|
+
"riverflow",
|
|
138
|
+
"grok-imagine",
|
|
139
|
+
"stable-diffusion",
|
|
140
|
+
"sdxl",
|
|
141
|
+
"midjourney",
|
|
142
|
+
"ideogram",
|
|
143
|
+
"nano-banana",
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
export function looksLikeImageGenerator(model: {
|
|
147
|
+
id: string;
|
|
148
|
+
name?: string;
|
|
149
|
+
output?: string[];
|
|
150
|
+
}): boolean {
|
|
151
|
+
// An explicit declaration always wins.
|
|
152
|
+
if (Array.isArray(model.output)) {
|
|
153
|
+
if (model.output.includes("image")) return true;
|
|
154
|
+
// Declared, but text-only — trust it and do not guess from the name.
|
|
155
|
+
if (model.output.length > 0) return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const haystack = `${model.id} ${model.name ?? ""}`.toLowerCase();
|
|
159
|
+
// Match "image" as a delimited segment ("gpt-5-image", "gemini-3-pro-image"),
|
|
160
|
+
// not the bare word anywhere — which would wrongly catch vision models such
|
|
161
|
+
// as "claude-image-understanding". \b handles the whitespace between id and
|
|
162
|
+
// name; a character class alone missed ids ending the id portion.
|
|
163
|
+
if (/(^|[/\-_\s])image\b/.test(haystack) && !haystack.includes("understand")) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
return GENERATOR_NAME_HINTS.some((hint) => haystack.includes(hint));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Image-generation models discovered from the chat registry — i.e. providers
|
|
171
|
+
* registered by other extensions, which pi-ai's built-in catalog knows nothing
|
|
172
|
+
* about.
|
|
173
|
+
*/
|
|
174
|
+
export function listRegistryImageGenModels(
|
|
175
|
+
registry: ChatModelRegistry,
|
|
176
|
+
): ImageGenModel[] {
|
|
177
|
+
let models: unknown[];
|
|
178
|
+
try {
|
|
179
|
+
models = registry.getAvailable?.() ?? registry.getAll();
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const out: ImageGenModel[] = [];
|
|
185
|
+
for (const model of models) {
|
|
186
|
+
if (model === null || typeof model !== "object") continue;
|
|
187
|
+
const candidate = model as Partial<ImageGenModel>;
|
|
188
|
+
if (typeof candidate.id !== "string" || typeof candidate.provider !== "string") {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (!looksLikeImageGenerator(candidate as ImageGenModel)) continue;
|
|
192
|
+
out.push({
|
|
193
|
+
id: candidate.id,
|
|
194
|
+
provider: candidate.provider,
|
|
195
|
+
name: candidate.name,
|
|
196
|
+
api: candidate.api ?? "",
|
|
197
|
+
...(candidate.output ? { output: candidate.output } : {}),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Every selectable generation model: pi-ai's built-in catalog plus anything
|
|
205
|
+
* contributed by registered providers, de-duplicated by "provider/id".
|
|
206
|
+
*/
|
|
207
|
+
export async function listAllImageGenModels(
|
|
208
|
+
registry?: ChatModelRegistry | null,
|
|
209
|
+
): Promise<ImageGenModel[]> {
|
|
210
|
+
const builtin = await listImageGenModels();
|
|
211
|
+
const fromRegistry = registry ? listRegistryImageGenModels(registry) : [];
|
|
212
|
+
|
|
213
|
+
const seen = new Set(builtin.map((m) => formatModelRef(m).toLowerCase()));
|
|
214
|
+
const merged = [...builtin];
|
|
215
|
+
for (const model of fromRegistry) {
|
|
216
|
+
const key = formatModelRef(model).toLowerCase();
|
|
217
|
+
if (seen.has(key)) continue;
|
|
218
|
+
seen.add(key);
|
|
219
|
+
merged.push(model);
|
|
220
|
+
}
|
|
221
|
+
return merged;
|
|
222
|
+
}
|
|
223
|
+
|
|
109
224
|
/** Inject a stub images collection. Test-only. */
|
|
110
225
|
export function __setImagesModelsForTests(models: ImagesModelsLike | null): void {
|
|
111
226
|
cachedImagesModels = models;
|
|
@@ -129,12 +244,18 @@ export function resolveImageGenModel(
|
|
|
129
244
|
input: string,
|
|
130
245
|
models: ImageGenModel[],
|
|
131
246
|
): ImageGenModel | string {
|
|
132
|
-
const
|
|
247
|
+
const raw = input.trim();
|
|
248
|
+
const query = raw.toLowerCase();
|
|
133
249
|
if (!query) return "No image model specified.";
|
|
134
250
|
if (models.length === 0) {
|
|
251
|
+
// A fully-qualified reference still works: detection is heuristic, so the
|
|
252
|
+
// user must be able to name a model we failed to discover.
|
|
253
|
+
const explicit = asExplicitModelRef(raw);
|
|
254
|
+
if (explicit) return explicit;
|
|
135
255
|
return (
|
|
136
256
|
"No image generation models are available.\n" +
|
|
137
|
-
"→ Image generation requires an OpenRouter account: https://openrouter.ai/keys"
|
|
257
|
+
"→ Image generation requires an OpenRouter account: https://openrouter.ai/keys\n" +
|
|
258
|
+
"→ Or set an exact model with /unipi:image-settings (press c to enter one manually)."
|
|
138
259
|
);
|
|
139
260
|
}
|
|
140
261
|
|
|
@@ -170,6 +291,11 @@ export function resolveImageGenModel(
|
|
|
170
291
|
|
|
171
292
|
if (best && bestScore > 0) return best;
|
|
172
293
|
|
|
294
|
+
// Nothing matched, but an explicit "provider/model-id" is taken at face
|
|
295
|
+
// value — the catalog is not authoritative for third-party providers.
|
|
296
|
+
const explicit = asExplicitModelRef(raw);
|
|
297
|
+
if (explicit) return explicit;
|
|
298
|
+
|
|
173
299
|
const sample = models.slice(0, 10).map((m) => ` ${formatModelRef(m)}`).join("\n");
|
|
174
300
|
return (
|
|
175
301
|
`Unknown image model "${input}".\n` +
|
|
@@ -178,6 +304,22 @@ export function resolveImageGenModel(
|
|
|
178
304
|
);
|
|
179
305
|
}
|
|
180
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Treat a well-formed "provider/model-id" as a usable model even when it is
|
|
309
|
+
* absent from the catalog.
|
|
310
|
+
*
|
|
311
|
+
* Generator detection is heuristic and third-party providers publish no image
|
|
312
|
+
* metadata, so refusing an unknown-but-well-formed reference would make some
|
|
313
|
+
* models permanently unreachable. Requiring the provider segment keeps this
|
|
314
|
+
* from swallowing plain typos, which still get the "Unknown image model" list.
|
|
315
|
+
*/
|
|
316
|
+
function asExplicitModelRef(raw: string): ImageGenModel | null {
|
|
317
|
+
const parts = splitModelRef(raw);
|
|
318
|
+
if (!parts) return null;
|
|
319
|
+
if (/\s/.test(raw)) return null;
|
|
320
|
+
return { id: parts.id, provider: parts.provider, api: "" };
|
|
321
|
+
}
|
|
322
|
+
|
|
181
323
|
/**
|
|
182
324
|
* List vision-capable chat models — those accepting image input.
|
|
183
325
|
*
|
|
@@ -216,8 +358,13 @@ export function resolveVisionModel(
|
|
|
216
358
|
registry: ChatModelRegistry,
|
|
217
359
|
): VisionModel | string {
|
|
218
360
|
const vision = listVisionModels(registry);
|
|
361
|
+
const raw = input.trim();
|
|
219
362
|
|
|
220
363
|
if (vision.length === 0) {
|
|
364
|
+
// Accept an explicit reference so a provider we cannot introspect is still
|
|
365
|
+
// usable (mirrors resolveImageGenModel).
|
|
366
|
+
const explicit = asExplicitVisionRef(raw);
|
|
367
|
+
if (explicit) return explicit;
|
|
221
368
|
return (
|
|
222
369
|
"No vision-capable models are configured.\n" +
|
|
223
370
|
"→ image_recognize needs a model that accepts image input " +
|
|
@@ -226,7 +373,7 @@ export function resolveVisionModel(
|
|
|
226
373
|
);
|
|
227
374
|
}
|
|
228
375
|
|
|
229
|
-
const query =
|
|
376
|
+
const query = raw.toLowerCase();
|
|
230
377
|
if (!query) return "No model specified.";
|
|
231
378
|
|
|
232
379
|
const exact = vision.find((m) => formatModelRef(m).toLowerCase() === query);
|
|
@@ -283,8 +430,22 @@ export function resolveVisionModel(
|
|
|
283
430
|
.join(", ")}`;
|
|
284
431
|
}
|
|
285
432
|
|
|
433
|
+
// Unknown to the registry, but well-formed — accept it. Checked after
|
|
434
|
+
// `knownButBlind` so a registered text-only model still gets the precise
|
|
435
|
+
// "does not accept image input" error rather than being waved through.
|
|
436
|
+
const explicit = asExplicitVisionRef(raw);
|
|
437
|
+
if (explicit) return explicit;
|
|
438
|
+
|
|
286
439
|
return (
|
|
287
440
|
`Unknown model "${input}".\n` +
|
|
288
441
|
`Vision-capable models: ${vision.map(formatModelRef).join(", ")}`
|
|
289
442
|
);
|
|
290
443
|
}
|
|
444
|
+
|
|
445
|
+
/** Accept a well-formed "provider/model-id" the registry does not know. */
|
|
446
|
+
function asExplicitVisionRef(raw: string): VisionModel | null {
|
|
447
|
+
const parts = splitModelRef(raw);
|
|
448
|
+
if (!parts) return null;
|
|
449
|
+
if (/\s/.test(raw)) return null;
|
|
450
|
+
return { id: parts.id, provider: parts.provider, input: ["text", "image"] };
|
|
451
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { generateImage } from "./generate.js";
|
|
|
14
14
|
import { loadImage } from "./image-source.js";
|
|
15
15
|
import {
|
|
16
16
|
formatModelRef,
|
|
17
|
-
|
|
17
|
+
listAllImageGenModels,
|
|
18
18
|
resolveImageGenModel,
|
|
19
19
|
resolveVisionModel,
|
|
20
20
|
splitModelRef,
|
|
@@ -108,7 +108,10 @@ function registerGenerateTool(pi: ExtensionAPI): void {
|
|
|
108
108
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
109
109
|
try {
|
|
110
110
|
const config = loadConfig();
|
|
111
|
-
const
|
|
111
|
+
const registry = getRegistry(ctx);
|
|
112
|
+
// Include image models contributed by registered providers, so the
|
|
113
|
+
// tool can resolve anything the settings picker offers.
|
|
114
|
+
const models = await listAllImageGenModels(registry);
|
|
112
115
|
|
|
113
116
|
const requested = params.model?.trim() || config.generate.model;
|
|
114
117
|
const resolved = resolveImageGenModel(requested, models);
|
|
@@ -116,7 +119,6 @@ function registerGenerateTool(pi: ExtensionAPI): void {
|
|
|
116
119
|
|
|
117
120
|
// pi-ai resolves image auth from its own credential store; only fall
|
|
118
121
|
// back to pi's chat-provider key when that comes up empty.
|
|
119
|
-
const registry = getRegistry(ctx);
|
|
120
122
|
const fallbackKey = await resolveApiKey(registry, resolved.provider);
|
|
121
123
|
|
|
122
124
|
const result = await generateImage({
|
|
@@ -28,6 +28,9 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
28
28
|
private saved = false;
|
|
29
29
|
private error: string | null = null;
|
|
30
30
|
private theme: Theme | null = null;
|
|
31
|
+
/** Free-text entry, for models the catalog does not know about. */
|
|
32
|
+
private customMode = false;
|
|
33
|
+
private custom = "";
|
|
31
34
|
|
|
32
35
|
onClose?: () => void;
|
|
33
36
|
onSelect?: (modelRef: string) => void;
|
|
@@ -56,6 +59,18 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
56
59
|
invalidate(): void {}
|
|
57
60
|
|
|
58
61
|
handleInput(data: string): void {
|
|
62
|
+
// Ctrl+C must always escape, even mid-filter. Without this the overlay traps
|
|
63
|
+
// the user with no way out.
|
|
64
|
+
if (data === "\x03") {
|
|
65
|
+
this.onClose?.();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (this.customMode) {
|
|
70
|
+
this.handleCustomInput(data);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
if (this.filterMode) {
|
|
60
75
|
this.handleFilterInput(data);
|
|
61
76
|
return;
|
|
@@ -74,6 +89,14 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
74
89
|
this.filterMode = true;
|
|
75
90
|
this.filter = "";
|
|
76
91
|
break;
|
|
92
|
+
case "c":
|
|
93
|
+
case "C":
|
|
94
|
+
// Escape hatch: generator detection is heuristic, so a provider may
|
|
95
|
+
// expose a model the catalog cannot recognise. Let the user name it.
|
|
96
|
+
this.customMode = true;
|
|
97
|
+
this.custom = "";
|
|
98
|
+
this.error = null;
|
|
99
|
+
break;
|
|
77
100
|
case "\r":
|
|
78
101
|
this.commit();
|
|
79
102
|
break;
|
|
@@ -108,6 +131,50 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
108
131
|
}
|
|
109
132
|
}
|
|
110
133
|
|
|
134
|
+
/** Free-text "provider/model-id" entry. */
|
|
135
|
+
private handleCustomInput(data: string): void {
|
|
136
|
+
if (data === "\r") {
|
|
137
|
+
this.commitCustom();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (data === "\x1b") {
|
|
141
|
+
this.customMode = false;
|
|
142
|
+
this.custom = "";
|
|
143
|
+
this.error = null;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (data === "\x7f" || data === "\b") {
|
|
147
|
+
this.custom = this.custom.slice(0, -1);
|
|
148
|
+
this.error = null;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (data.length === 1 && data >= " ") {
|
|
152
|
+
this.custom += data;
|
|
153
|
+
this.error = null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private commitCustom(): void {
|
|
158
|
+
const ref = this.custom.trim();
|
|
159
|
+
if (!ref) {
|
|
160
|
+
this.error = "Enter a model as provider/model-id";
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Validate here rather than letting a malformed ref fail later with an
|
|
164
|
+
// opaque provider error.
|
|
165
|
+
const slash = ref.indexOf("/");
|
|
166
|
+
if (slash <= 0 || slash === ref.length - 1) {
|
|
167
|
+
this.error = `"${ref}" must be in the form provider/model-id`;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
this.customMode = false;
|
|
172
|
+
this.onSelect?.(ref);
|
|
173
|
+
this.saved = true;
|
|
174
|
+
this.error = null;
|
|
175
|
+
this.onClose?.();
|
|
176
|
+
}
|
|
177
|
+
|
|
111
178
|
private clampSelection(): void {
|
|
112
179
|
this.selectedIndex = Math.min(
|
|
113
180
|
this.selectedIndex,
|
|
@@ -137,7 +204,9 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
137
204
|
this.onSelect?.(`${model.provider}/${model.id}`);
|
|
138
205
|
this.saved = true;
|
|
139
206
|
this.error = null;
|
|
140
|
-
|
|
207
|
+
// Close immediately. A deferred close leaves the overlay focused while the
|
|
208
|
+
// caller resumes, which is what let input reach two components at once.
|
|
209
|
+
this.onClose?.();
|
|
141
210
|
}
|
|
142
211
|
|
|
143
212
|
// ─── Theme helpers ───────────────────────────────────────────────────
|
|
@@ -194,7 +263,20 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
194
263
|
lines.push(this.ruleLine(innerWidth));
|
|
195
264
|
|
|
196
265
|
// Filter bar
|
|
197
|
-
if (this.
|
|
266
|
+
if (this.customMode) {
|
|
267
|
+
lines.push(
|
|
268
|
+
this.frameLine(
|
|
269
|
+
` ${this.fg("accent", "Model:")} ${this.custom}${this.fg("accent", "█")}`,
|
|
270
|
+
innerWidth,
|
|
271
|
+
),
|
|
272
|
+
);
|
|
273
|
+
lines.push(
|
|
274
|
+
this.frameLine(
|
|
275
|
+
` ${this.fg("dim", "e.g. omniroute/fal/fal-ai/flux-2-pro")}`,
|
|
276
|
+
innerWidth,
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
} else if (this.filterMode) {
|
|
198
280
|
lines.push(
|
|
199
281
|
this.frameLine(
|
|
200
282
|
` ${this.fg("accent", "Filter:")} ${this.filter}${this.fg("accent", "█")}`,
|
|
@@ -211,7 +293,7 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
211
293
|
} else {
|
|
212
294
|
lines.push(
|
|
213
295
|
this.frameLine(
|
|
214
|
-
` ${this.fg("dim", `${this.models.length} models ·
|
|
296
|
+
` ${this.fg("dim", `${this.models.length} models · / filter · c custom`)}`,
|
|
215
297
|
innerWidth,
|
|
216
298
|
),
|
|
217
299
|
);
|
|
@@ -224,7 +306,9 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
224
306
|
const start = Math.max(0, this.selectedIndex - Math.floor(maxVisible / 2));
|
|
225
307
|
const end = Math.min(this.filtered.length, start + maxVisible);
|
|
226
308
|
|
|
227
|
-
if (this.
|
|
309
|
+
if (this.customMode) {
|
|
310
|
+
// The list is noise while typing a model reference.
|
|
311
|
+
} else if (this.filtered.length === 0) {
|
|
228
312
|
const empty =
|
|
229
313
|
this.models.length === 0
|
|
230
314
|
? this.kind === "generate"
|
|
@@ -246,7 +330,7 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
246
330
|
}
|
|
247
331
|
}
|
|
248
332
|
|
|
249
|
-
if (this.filtered.length > maxVisible) {
|
|
333
|
+
if (!this.customMode && this.filtered.length > maxVisible) {
|
|
250
334
|
const pct = Math.round(((this.selectedIndex + 1) / this.filtered.length) * 100);
|
|
251
335
|
lines.push(
|
|
252
336
|
this.frameLine(
|
|
@@ -268,7 +352,12 @@ export class ImageModelSelectorOverlay implements Component {
|
|
|
268
352
|
lines.push(this.ruleLine(innerWidth));
|
|
269
353
|
lines.push(
|
|
270
354
|
this.frameLine(
|
|
271
|
-
this.fg(
|
|
355
|
+
this.fg(
|
|
356
|
+
"dim",
|
|
357
|
+
this.customMode
|
|
358
|
+
? "Enter save · Esc back to list"
|
|
359
|
+
: "↑↓ navigate · / filter · c custom · Enter select · Esc cancel",
|
|
360
|
+
),
|
|
272
361
|
innerWidth,
|
|
273
362
|
),
|
|
274
363
|
);
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "../settings.js";
|
|
18
18
|
import {
|
|
19
19
|
formatModelRef,
|
|
20
|
-
|
|
20
|
+
listAllImageGenModels,
|
|
21
21
|
listVisionModels,
|
|
22
22
|
type ChatModelRegistry,
|
|
23
23
|
} from "../models.js";
|
|
@@ -165,36 +165,41 @@ async function pickModel(
|
|
|
165
165
|
): Promise<void> {
|
|
166
166
|
const models = await collectModels(ctx, kind);
|
|
167
167
|
|
|
168
|
+
if (!ctx.hasUI) {
|
|
169
|
+
ctx.ui.notify("Model selection requires an interactive UI.", "warning");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// An empty catalog is NOT a dead end: the overlay's custom entry (`c`) is
|
|
174
|
+
// precisely the escape hatch for a provider whose models we cannot detect.
|
|
168
175
|
if (models.length === 0) {
|
|
169
176
|
ctx.ui.notify(
|
|
170
177
|
kind === "generate"
|
|
171
|
-
? "No image models
|
|
172
|
-
: "No vision-capable models
|
|
178
|
+
? "No image models detected — press c to enter one manually."
|
|
179
|
+
: "No vision-capable models detected — press c to enter one manually.",
|
|
173
180
|
"warning",
|
|
174
181
|
);
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (!ctx.hasUI) {
|
|
179
|
-
ctx.ui.notify("Model selection requires an interactive UI.", "warning");
|
|
180
|
-
return;
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
const current =
|
|
184
185
|
kind === "generate" ? config.generate.model : config.recognize.model;
|
|
185
186
|
|
|
186
|
-
|
|
187
|
-
|
|
187
|
+
// `ctx.ui.custom` resolves only when the factory calls `done()`. It MUST be
|
|
188
|
+
// awaited: returning early leaves the overlay on screen while the settings
|
|
189
|
+
// loop mounts the next `ctx.ui.select`, so two focused components fight over
|
|
190
|
+
// the same keystrokes and neither can be closed.
|
|
191
|
+
let picked: string | undefined;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
picked = await ctx.ui.custom<string | undefined>(
|
|
188
195
|
(tui, theme, _keybindings, done) => {
|
|
189
196
|
const overlay = new ImageModelSelectorOverlay(kind, models, current);
|
|
190
197
|
overlay.setTheme(theme);
|
|
198
|
+
overlay.requestRender = () => tui.requestRender();
|
|
191
199
|
overlay.onSelect = (modelRef) => {
|
|
192
|
-
|
|
193
|
-
if (kind === "generate") next.generate.model = modelRef;
|
|
194
|
-
else next.recognize.model = modelRef;
|
|
195
|
-
saveConfig(next);
|
|
200
|
+
picked = modelRef;
|
|
196
201
|
};
|
|
197
|
-
overlay.onClose = () => done(
|
|
202
|
+
overlay.onClose = () => done(picked);
|
|
198
203
|
return {
|
|
199
204
|
render: (width: number) => overlay.render(width),
|
|
200
205
|
invalidate: () => overlay.invalidate(),
|
|
@@ -209,21 +214,36 @@ async function pickModel(
|
|
|
209
214
|
overlayOptions: { width: "80%", minWidth: 50, anchor: "center", margin: 2 },
|
|
210
215
|
},
|
|
211
216
|
);
|
|
212
|
-
|
|
213
|
-
|
|
217
|
+
} catch (err) {
|
|
218
|
+
ctx.ui.notify(`Model selector error: ${err}`, "error");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Persist only after the overlay has fully closed, so a cancel leaves the
|
|
223
|
+
// existing config untouched.
|
|
224
|
+
if (!picked) return;
|
|
225
|
+
|
|
226
|
+
const next = loadConfig();
|
|
227
|
+
if (kind === "generate") next.generate.model = picked;
|
|
228
|
+
else next.recognize.model = picked;
|
|
229
|
+
saveConfig(next);
|
|
230
|
+
ctx.ui.notify(`${kind === "generate" ? "Generation" : "Recognition"} model set to ${picked}`, "info");
|
|
214
231
|
}
|
|
215
232
|
|
|
216
233
|
async function collectModels(
|
|
217
234
|
ctx: ExtensionCommandContext,
|
|
218
235
|
kind: "generate" | "recognize",
|
|
219
236
|
): Promise<SelectableModel[]> {
|
|
237
|
+
const registry = (ctx as unknown as { modelRegistry?: ChatModelRegistry })
|
|
238
|
+
.modelRegistry;
|
|
239
|
+
|
|
220
240
|
if (kind === "generate") {
|
|
221
|
-
|
|
241
|
+
// Include models from providers registered by other extensions, not just
|
|
242
|
+
// pi-ai's built-in OpenRouter catalog.
|
|
243
|
+
const models = await listAllImageGenModels(registry);
|
|
222
244
|
return models.map((m) => ({ provider: m.provider, id: m.id, name: m.name }));
|
|
223
245
|
}
|
|
224
246
|
|
|
225
|
-
const registry = (ctx as unknown as { modelRegistry?: ChatModelRegistry })
|
|
226
|
-
.modelRegistry;
|
|
227
247
|
if (!registry) return [];
|
|
228
248
|
|
|
229
249
|
return listVisionModels(registry).map((m) => ({
|