@pi-unipi/image 2.2.3 → 2.2.5

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/image",
3
- "version": "2.2.3",
3
+ "version": "2.2.5",
4
4
  "description": "Image generation and image recognition tools for the Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/generate.ts CHANGED
@@ -103,6 +103,26 @@ export function saveImage(
103
103
  }
104
104
  }
105
105
 
106
+ /** Providers pi-ai's images collection can actually generate with. */
107
+ function supportedProviders(imagesApi: ImagesModelsLike): string[] {
108
+ try {
109
+ return [...new Set(imagesApi.getModels().map((m) => m.provider))];
110
+ } catch {
111
+ return [];
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Whether generation can route to a provider. Unknown/empty catalogs are
117
+ * treated as capable so a stubbed or future pi-ai is never blocked by this
118
+ * check — the real call still reports its own error.
119
+ */
120
+ function providerCanGenerate(imagesApi: ImagesModelsLike, provider: string): boolean {
121
+ const providers = supportedProviders(imagesApi);
122
+ if (providers.length === 0) return true;
123
+ return providers.includes(provider);
124
+ }
125
+
106
126
  export interface GenerateOptions {
107
127
  prompt: string;
108
128
  model: ImageGenModel;
@@ -134,6 +154,22 @@ export async function generateImage(options: GenerateOptions): Promise<GenerateR
134
154
  );
135
155
  }
136
156
 
157
+ // pi-ai's images collection carries its own provider set (currently only
158
+ // `openrouter`) and is entirely separate from pi's chat model registry.
159
+ // A chat provider registered by another extension can therefore list image
160
+ // models that generation cannot actually drive — pi-ai answers with a bare
161
+ // "Unknown provider: x". Detect that here and say something useful.
162
+ if (!providerCanGenerate(imagesApi, model.provider)) {
163
+ const supported = supportedProviders(imagesApi);
164
+ throw new Error(
165
+ `Provider "${model.provider}" cannot generate images.\n` +
166
+ `→ Image generation is served by: ${supported.join(", ") || "openrouter"}.\n` +
167
+ `→ "${model.provider}" is a chat provider; its image models are listed for ` +
168
+ `recognition and reference, but generation must go through a supported provider.\n` +
169
+ "→ Pick one with /unipi:image-settings.",
170
+ );
171
+ }
172
+
137
173
  // Prefer pi-ai's own credential store, then the caller-supplied fallback so
138
174
  // a bare OPENROUTER_API_KEY still works.
139
175
  let apiKey: string | undefined;
package/src/models.ts CHANGED
@@ -105,6 +105,23 @@ export async function getImagesModels(): Promise<ImagesModelsLike | null> {
105
105
  return cachedImagesModels;
106
106
  }
107
107
 
108
+ /**
109
+ * Providers pi-ai's images collection can actually generate with.
110
+ *
111
+ * This is NOT the same set as pi's chat model registry: a chat provider
112
+ * registered by another extension may list image models that image generation
113
+ * cannot drive. Empty means "unknown", which callers treat as permissive.
114
+ */
115
+ export async function getGeneratingProviders(): Promise<string[]> {
116
+ const images = await getImagesModels();
117
+ if (!images) return [];
118
+ try {
119
+ return [...new Set(images.getModels().map((m) => m.provider))];
120
+ } catch {
121
+ return [];
122
+ }
123
+ }
124
+
108
125
  /** List available image-generation models. Empty when unavailable. */
109
126
  export async function listImageGenModels(): Promise<ImageGenModel[]> {
110
127
  const images = await getImagesModels();
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { Component } from "@earendil-works/pi-tui";
9
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
11
11
  import { boxInnerWidth, safeRepeat } from "@pi-unipi/core";
12
12
 
@@ -14,6 +14,8 @@ export interface SelectableModel {
14
14
  provider: string;
15
15
  id: string;
16
16
  name?: string;
17
+ /** Set when the model is listed but not usable, with the reason. */
18
+ unavailable?: string;
17
19
  }
18
20
 
19
21
  export type ModelSelectorKind = "generate" | "recognize";
@@ -61,7 +63,7 @@ export class ImageModelSelectorOverlay implements Component {
61
63
  handleInput(data: string): void {
62
64
  // Ctrl+C must always escape, even mid-filter. Without this the overlay traps
63
65
  // the user with no way out.
64
- if (data === "\x03") {
66
+ if (matchesKey(data, "ctrl+c")) {
65
67
  this.onClose?.();
66
68
  return;
67
69
  }
@@ -76,49 +78,61 @@ export class ImageModelSelectorOverlay implements Component {
76
78
  return;
77
79
  }
78
80
 
79
- switch (data) {
80
- case "\x1b[A":
81
- case "k":
82
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
83
- break;
84
- case "\x1b[B":
85
- case "j":
86
- this.selectedIndex = Math.min(this.filtered.length - 1, this.selectedIndex + 1);
87
- break;
88
- case "/":
89
- this.filterMode = true;
90
- this.filter = "";
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;
100
- case "\r":
101
- this.commit();
102
- break;
103
- case "\x1b":
104
- this.onClose?.();
105
- break;
81
+ // Escape is checked via matchesKey, not `data === "\x1b"`: under the kitty
82
+ // keyboard protocol it arrives as "\x1b[27u" (and as "\x1b[27;1;27~" with
83
+ // modifyOtherKeys), so a bare comparison silently fails to close.
84
+ if (matchesKey(data, "escape")) {
85
+ this.onClose?.();
86
+ return;
87
+ }
88
+ if (matchesKey(data, "up") || data === "k") {
89
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
90
+ return;
91
+ }
92
+ if (matchesKey(data, "down") || data === "j") {
93
+ this.selectedIndex = Math.min(this.filtered.length - 1, this.selectedIndex + 1);
94
+ return;
95
+ }
96
+ if (matchesKey(data, "enter")) {
97
+ this.commit();
98
+ return;
99
+ }
100
+ if (data === "/") {
101
+ this.filterMode = true;
102
+ this.filter = "";
103
+ return;
104
+ }
105
+ if (data === "c" || data === "C") {
106
+ // Escape hatch: generator detection is heuristic, so a provider may
107
+ // expose a model the catalog cannot recognise. Let the user name it.
108
+ this.customMode = true;
109
+ this.custom = "";
110
+ this.error = null;
106
111
  }
107
112
  }
108
113
 
109
114
  private handleFilterInput(data: string): void {
110
- if (data === "\r") {
115
+ if (matchesKey(data, "enter")) {
111
116
  this.filterMode = false;
112
117
  return;
113
118
  }
114
- if (data === "\x1b") {
119
+ if (matchesKey(data, "escape")) {
115
120
  this.filter = "";
116
121
  this.filterMode = false;
117
122
  this.applyFilter();
118
123
  this.selectedIndex = 0;
119
124
  return;
120
125
  }
121
- if (data === "\x7f" || data === "\b") {
126
+ // Let the list be navigated without leaving the filter.
127
+ if (matchesKey(data, "up")) {
128
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
129
+ return;
130
+ }
131
+ if (matchesKey(data, "down")) {
132
+ this.selectedIndex = Math.min(this.filtered.length - 1, this.selectedIndex + 1);
133
+ return;
134
+ }
135
+ if (matchesKey(data, "backspace") || data === "\x7f" || data === "\b") {
122
136
  this.filter = this.filter.slice(0, -1);
123
137
  this.applyFilter();
124
138
  this.clampSelection();
@@ -133,17 +147,17 @@ export class ImageModelSelectorOverlay implements Component {
133
147
 
134
148
  /** Free-text "provider/model-id" entry. */
135
149
  private handleCustomInput(data: string): void {
136
- if (data === "\r") {
150
+ if (matchesKey(data, "enter")) {
137
151
  this.commitCustom();
138
152
  return;
139
153
  }
140
- if (data === "\x1b") {
154
+ if (matchesKey(data, "escape")) {
141
155
  this.customMode = false;
142
156
  this.custom = "";
143
157
  this.error = null;
144
158
  return;
145
159
  }
146
- if (data === "\x7f" || data === "\b") {
160
+ if (matchesKey(data, "backspace") || data === "\x7f" || data === "\b") {
147
161
  this.custom = this.custom.slice(0, -1);
148
162
  this.error = null;
149
163
  return;
@@ -200,6 +214,12 @@ export class ImageModelSelectorOverlay implements Component {
200
214
  this.error = "No model selected";
201
215
  return;
202
216
  }
217
+ // Require a deliberate second Enter on a model that cannot be used, rather
218
+ // than silently saving a choice that will fail at call time.
219
+ if (model.unavailable && this.error === null) {
220
+ this.error = `${model.id} ${model.unavailable} — press Enter again to select anyway`;
221
+ return;
222
+ }
203
223
 
204
224
  this.onSelect?.(`${model.provider}/${model.id}`);
205
225
  this.saved = true;
@@ -323,9 +343,12 @@ export class ImageModelSelectorOverlay implements Component {
323
343
  const marker = isSelected ? this.fg("accent", "▸") : " ";
324
344
  const label = model.name || model.id;
325
345
  const providerTag = this.fg("dim", `[${model.provider}]`);
326
- const display = isSelected
346
+ const base = isSelected
327
347
  ? `${providerTag} ${this.bold(label)}`
328
348
  : `${providerTag} ${this.fg("dim", label)}`;
349
+ const display = model.unavailable
350
+ ? `${base} ${this.fg("warning", `(${model.unavailable})`)}`
351
+ : base;
329
352
  lines.push(this.frameLine(` ${marker} ${display}`, innerWidth));
330
353
  }
331
354
  }
@@ -17,6 +17,7 @@ import {
17
17
  } from "../settings.js";
18
18
  import {
19
19
  formatModelRef,
20
+ getGeneratingProviders,
20
21
  listAllImageGenModels,
21
22
  listVisionModels,
22
23
  type ChatModelRegistry,
@@ -241,7 +242,20 @@ async function collectModels(
241
242
  // Include models from providers registered by other extensions, not just
242
243
  // pi-ai's built-in OpenRouter catalog.
243
244
  const models = await listAllImageGenModels(registry);
244
- return models.map((m) => ({ provider: m.provider, id: m.id, name: m.name }));
245
+ const generating = await getGeneratingProviders();
246
+
247
+ return models.map((m) => ({
248
+ provider: m.provider,
249
+ id: m.id,
250
+ name: m.name,
251
+ // pi-ai's images collection has its own provider set. A chat provider's
252
+ // image models are listed for reference but cannot actually generate,
253
+ // so flag them rather than letting the user pick a dead option.
254
+ unavailable:
255
+ generating.length > 0 && !generating.includes(m.provider)
256
+ ? "cannot generate"
257
+ : undefined,
258
+ }));
245
259
  }
246
260
 
247
261
  if (!registry) return [];