@pi-unipi/unipi 2.2.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/image",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Image generation and image recognition tools for the Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -72,10 +72,20 @@ let imagesModelsAttempted = false;
72
72
  /**
73
73
  * Load pi-ai's built-in images collection.
74
74
  *
75
- * `getImageModels`/`generateImages` are not re-exported from the pi-ai package
76
- * root, but `providers/all` exports `builtinImagesModels()`, which is the
77
- * supported entry point and also resolves auth. A failure is not fatal: it
78
- * degrades to null and callers report that no models are available.
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;
@@ -14,7 +14,7 @@ import { generateImage } from "./generate.js";
14
14
  import { loadImage } from "./image-source.js";
15
15
  import {
16
16
  formatModelRef,
17
- listImageGenModels,
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 models = await listImageGenModels();
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({
@@ -56,6 +56,13 @@ export class ImageModelSelectorOverlay implements Component {
56
56
  invalidate(): void {}
57
57
 
58
58
  handleInput(data: string): void {
59
+ // Ctrl+C must always escape, even mid-filter. Without this the overlay traps
60
+ // the user with no way out.
61
+ if (data === "\x03") {
62
+ this.onClose?.();
63
+ return;
64
+ }
65
+
59
66
  if (this.filterMode) {
60
67
  this.handleFilterInput(data);
61
68
  return;
@@ -137,7 +144,9 @@ export class ImageModelSelectorOverlay implements Component {
137
144
  this.onSelect?.(`${model.provider}/${model.id}`);
138
145
  this.saved = true;
139
146
  this.error = null;
140
- setTimeout(() => this.onClose?.(), 500);
147
+ // Close immediately. A deferred close leaves the overlay focused while the
148
+ // caller resumes, which is what let input reach two components at once.
149
+ this.onClose?.();
141
150
  }
142
151
 
143
152
  // ─── Theme helpers ───────────────────────────────────────────────────
@@ -17,7 +17,7 @@ import {
17
17
  } from "../settings.js";
18
18
  import {
19
19
  formatModelRef,
20
- listImageGenModels,
20
+ listAllImageGenModels,
21
21
  listVisionModels,
22
22
  type ChatModelRegistry,
23
23
  } from "../models.js";
@@ -168,7 +168,7 @@ async function pickModel(
168
168
  if (models.length === 0) {
169
169
  ctx.ui.notify(
170
170
  kind === "generate"
171
- ? "No image models available. Image generation is served through OpenRouter add a key with /login."
171
+ ? "No image models available. Add an OpenRouter key with /login, or register a provider that exposes image models."
172
172
  : "No vision-capable models configured. Add a model that accepts image input.",
173
173
  "warning",
174
174
  );
@@ -183,18 +183,22 @@ async function pickModel(
183
183
  const current =
184
184
  kind === "generate" ? config.generate.model : config.recognize.model;
185
185
 
186
- await new Promise<void>((resolve) => {
187
- ctx.ui.custom(
186
+ // `ctx.ui.custom` resolves only when the factory calls `done()`. It MUST be
187
+ // awaited: returning early leaves the overlay on screen while the settings
188
+ // loop mounts the next `ctx.ui.select`, so two focused components fight over
189
+ // the same keystrokes and neither can be closed.
190
+ let picked: string | undefined;
191
+
192
+ try {
193
+ picked = await ctx.ui.custom<string | undefined>(
188
194
  (tui, theme, _keybindings, done) => {
189
195
  const overlay = new ImageModelSelectorOverlay(kind, models, current);
190
196
  overlay.setTheme(theme);
197
+ overlay.requestRender = () => tui.requestRender();
191
198
  overlay.onSelect = (modelRef) => {
192
- const next = loadConfig();
193
- if (kind === "generate") next.generate.model = modelRef;
194
- else next.recognize.model = modelRef;
195
- saveConfig(next);
199
+ picked = modelRef;
196
200
  };
197
- overlay.onClose = () => done(undefined);
201
+ overlay.onClose = () => done(picked);
198
202
  return {
199
203
  render: (width: number) => overlay.render(width),
200
204
  invalidate: () => overlay.invalidate(),
@@ -209,21 +213,36 @@ async function pickModel(
209
213
  overlayOptions: { width: "80%", minWidth: 50, anchor: "center", margin: 2 },
210
214
  },
211
215
  );
212
- resolve();
213
- });
216
+ } catch (err) {
217
+ ctx.ui.notify(`Model selector error: ${err}`, "error");
218
+ return;
219
+ }
220
+
221
+ // Persist only after the overlay has fully closed, so a cancel leaves the
222
+ // existing config untouched.
223
+ if (!picked) return;
224
+
225
+ const next = loadConfig();
226
+ if (kind === "generate") next.generate.model = picked;
227
+ else next.recognize.model = picked;
228
+ saveConfig(next);
229
+ ctx.ui.notify(`${kind === "generate" ? "Generation" : "Recognition"} model set to ${picked}`, "info");
214
230
  }
215
231
 
216
232
  async function collectModels(
217
233
  ctx: ExtensionCommandContext,
218
234
  kind: "generate" | "recognize",
219
235
  ): Promise<SelectableModel[]> {
236
+ const registry = (ctx as unknown as { modelRegistry?: ChatModelRegistry })
237
+ .modelRegistry;
238
+
220
239
  if (kind === "generate") {
221
- const models = await listImageGenModels();
240
+ // Include models from providers registered by other extensions, not just
241
+ // pi-ai's built-in OpenRouter catalog.
242
+ const models = await listAllImageGenModels(registry);
222
243
  return models.map((m) => ({ provider: m.provider, id: m.id, name: m.name }));
223
244
  }
224
245
 
225
- const registry = (ctx as unknown as { modelRegistry?: ChatModelRegistry })
226
- .modelRegistry;
227
246
  if (!registry) return [];
228
247
 
229
248
  return listVisionModels(registry).map((m) => ({