@pi-unipi/image 2.2.0

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.
@@ -0,0 +1,279 @@
1
+ /**
2
+ * @pi-unipi/image — Model selector overlay
3
+ *
4
+ * Picks either an image-generation model (from pi-ai's image catalog) or a
5
+ * vision model (from pi's chat registry, filtered to image-capable models).
6
+ */
7
+
8
+ import type { Component } from "@earendil-works/pi-tui";
9
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
+ import type { Theme } from "@earendil-works/pi-coding-agent";
11
+ import { boxInnerWidth, safeRepeat } from "@pi-unipi/core";
12
+
13
+ export interface SelectableModel {
14
+ provider: string;
15
+ id: string;
16
+ name?: string;
17
+ }
18
+
19
+ export type ModelSelectorKind = "generate" | "recognize";
20
+
21
+ /** Overlay listing models with filter, navigation and selection. */
22
+ export class ImageModelSelectorOverlay implements Component {
23
+ private models: SelectableModel[] = [];
24
+ private filtered: SelectableModel[] = [];
25
+ private selectedIndex = 0;
26
+ private filter = "";
27
+ private filterMode = false;
28
+ private saved = false;
29
+ private error: string | null = null;
30
+ private theme: Theme | null = null;
31
+
32
+ onClose?: () => void;
33
+ onSelect?: (modelRef: string) => void;
34
+ requestRender?: () => void;
35
+
36
+ constructor(
37
+ private readonly kind: ModelSelectorKind,
38
+ models: SelectableModel[],
39
+ currentRef?: string,
40
+ ) {
41
+ this.models = models;
42
+ this.applyFilter();
43
+
44
+ if (currentRef) {
45
+ const index = this.filtered.findIndex(
46
+ (m) => `${m.provider}/${m.id}` === currentRef,
47
+ );
48
+ if (index >= 0) this.selectedIndex = index;
49
+ }
50
+ }
51
+
52
+ setTheme(theme: Theme): void {
53
+ this.theme = theme;
54
+ }
55
+
56
+ invalidate(): void {}
57
+
58
+ handleInput(data: string): void {
59
+ if (this.filterMode) {
60
+ this.handleFilterInput(data);
61
+ return;
62
+ }
63
+
64
+ switch (data) {
65
+ case "\x1b[A":
66
+ case "k":
67
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
68
+ break;
69
+ case "\x1b[B":
70
+ case "j":
71
+ this.selectedIndex = Math.min(this.filtered.length - 1, this.selectedIndex + 1);
72
+ break;
73
+ case "/":
74
+ this.filterMode = true;
75
+ this.filter = "";
76
+ break;
77
+ case "\r":
78
+ this.commit();
79
+ break;
80
+ case "\x1b":
81
+ this.onClose?.();
82
+ break;
83
+ }
84
+ }
85
+
86
+ private handleFilterInput(data: string): void {
87
+ if (data === "\r") {
88
+ this.filterMode = false;
89
+ return;
90
+ }
91
+ if (data === "\x1b") {
92
+ this.filter = "";
93
+ this.filterMode = false;
94
+ this.applyFilter();
95
+ this.selectedIndex = 0;
96
+ return;
97
+ }
98
+ if (data === "\x7f" || data === "\b") {
99
+ this.filter = this.filter.slice(0, -1);
100
+ this.applyFilter();
101
+ this.clampSelection();
102
+ return;
103
+ }
104
+ if (data.length === 1 && data >= " ") {
105
+ this.filter += data;
106
+ this.applyFilter();
107
+ this.clampSelection();
108
+ }
109
+ }
110
+
111
+ private clampSelection(): void {
112
+ this.selectedIndex = Math.min(
113
+ this.selectedIndex,
114
+ Math.max(0, this.filtered.length - 1),
115
+ );
116
+ }
117
+
118
+ private applyFilter(): void {
119
+ const query = this.filter.toLowerCase();
120
+ this.filtered = query
121
+ ? this.models.filter(
122
+ (m) =>
123
+ m.id.toLowerCase().includes(query) ||
124
+ m.provider.toLowerCase().includes(query) ||
125
+ (m.name?.toLowerCase().includes(query) ?? false),
126
+ )
127
+ : [...this.models];
128
+ }
129
+
130
+ private commit(): void {
131
+ const model = this.filtered[this.selectedIndex];
132
+ if (!model) {
133
+ this.error = "No model selected";
134
+ return;
135
+ }
136
+
137
+ this.onSelect?.(`${model.provider}/${model.id}`);
138
+ this.saved = true;
139
+ this.error = null;
140
+ setTimeout(() => this.onClose?.(), 500);
141
+ }
142
+
143
+ // ─── Theme helpers ───────────────────────────────────────────────────
144
+
145
+ private fg(color: string, text: string): string {
146
+ if (this.theme) return this.theme.fg(color as never, text);
147
+ const codes: Record<string, string> = {
148
+ accent: "\x1b[36m",
149
+ success: "\x1b[32m",
150
+ warning: "\x1b[33m",
151
+ error: "\x1b[31m",
152
+ dim: "\x1b[2m",
153
+ borderMuted: "\x1b[90m",
154
+ };
155
+ return `${codes[color] ?? ""}${text}\x1b[0m`;
156
+ }
157
+
158
+ private bold(text: string): string {
159
+ return this.theme ? this.theme.bold(text) : `\x1b[1m${text}\x1b[0m`;
160
+ }
161
+
162
+ private frameLine(content: string, innerWidth: number): string {
163
+ const truncated = truncateToWidth(content, innerWidth, "");
164
+ const padding = safeRepeat(" ", innerWidth - visibleWidth(truncated));
165
+ return `${this.fg("borderMuted", "│")}${truncated}${padding}${this.fg("borderMuted", "│")}`;
166
+ }
167
+
168
+ private ruleLine(innerWidth: number): string {
169
+ return this.fg("borderMuted", `├${safeRepeat("─", innerWidth)}┤`);
170
+ }
171
+
172
+ private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
173
+ const left = edge === "top" ? "┌" : "└";
174
+ const right = edge === "top" ? "┐" : "┘";
175
+ return this.fg("borderMuted", `${left}${safeRepeat("─", innerWidth)}${right}`);
176
+ }
177
+
178
+ render(width: number): string[] {
179
+ const innerWidth = boxInnerWidth(width);
180
+ const lines: string[] = [];
181
+
182
+ const title =
183
+ this.kind === "generate"
184
+ ? "🎨 Image Generation Model"
185
+ : "👁 Image Recognition Model";
186
+ const subtitle =
187
+ this.kind === "generate"
188
+ ? "Model used by image_generate"
189
+ : "Vision model used by image_recognize";
190
+
191
+ lines.push(this.borderLine(innerWidth, "top"));
192
+ lines.push(this.frameLine(this.fg("accent", this.bold(title)), innerWidth));
193
+ lines.push(this.frameLine(this.fg("dim", subtitle), innerWidth));
194
+ lines.push(this.ruleLine(innerWidth));
195
+
196
+ // Filter bar
197
+ if (this.filterMode) {
198
+ lines.push(
199
+ this.frameLine(
200
+ ` ${this.fg("accent", "Filter:")} ${this.filter}${this.fg("accent", "█")}`,
201
+ innerWidth,
202
+ ),
203
+ );
204
+ } else if (this.filter) {
205
+ lines.push(
206
+ this.frameLine(
207
+ ` ${this.fg("dim", "Filter:")} ${this.filter} ${this.fg("dim", "(press / to edit)")}`,
208
+ innerWidth,
209
+ ),
210
+ );
211
+ } else {
212
+ lines.push(
213
+ this.frameLine(
214
+ ` ${this.fg("dim", `${this.models.length} models · press / to filter`)}`,
215
+ innerWidth,
216
+ ),
217
+ );
218
+ }
219
+ lines.push(this.ruleLine(innerWidth));
220
+
221
+ // Model list
222
+ const terminalRows = process.stdout.rows ?? 30;
223
+ const maxVisible = Math.max(5, terminalRows - 14);
224
+ const start = Math.max(0, this.selectedIndex - Math.floor(maxVisible / 2));
225
+ const end = Math.min(this.filtered.length, start + maxVisible);
226
+
227
+ if (this.filtered.length === 0) {
228
+ const empty =
229
+ this.models.length === 0
230
+ ? this.kind === "generate"
231
+ ? "No image models available (needs OpenRouter)"
232
+ : "No vision-capable models configured"
233
+ : "No models match the filter";
234
+ lines.push(this.frameLine(` ${this.fg("dim", empty)}`, innerWidth));
235
+ } else {
236
+ for (let i = start; i < end; i++) {
237
+ const model = this.filtered[i];
238
+ const isSelected = i === this.selectedIndex;
239
+ const marker = isSelected ? this.fg("accent", "▸") : " ";
240
+ const label = model.name || model.id;
241
+ const providerTag = this.fg("dim", `[${model.provider}]`);
242
+ const display = isSelected
243
+ ? `${providerTag} ${this.bold(label)}`
244
+ : `${providerTag} ${this.fg("dim", label)}`;
245
+ lines.push(this.frameLine(` ${marker} ${display}`, innerWidth));
246
+ }
247
+ }
248
+
249
+ if (this.filtered.length > maxVisible) {
250
+ const pct = Math.round(((this.selectedIndex + 1) / this.filtered.length) * 100);
251
+ lines.push(
252
+ this.frameLine(
253
+ this.fg("dim", ` ${pct}% (${this.selectedIndex + 1}/${this.filtered.length})`),
254
+ innerWidth,
255
+ ),
256
+ );
257
+ }
258
+
259
+ if (this.error) {
260
+ lines.push(this.ruleLine(innerWidth));
261
+ lines.push(this.frameLine(` ${this.fg("error", `⚠ ${this.error}`)}`, innerWidth));
262
+ }
263
+ if (this.saved) {
264
+ lines.push(this.ruleLine(innerWidth));
265
+ lines.push(this.frameLine(` ${this.fg("success", "✓ Model saved")}`, innerWidth));
266
+ }
267
+
268
+ lines.push(this.ruleLine(innerWidth));
269
+ lines.push(
270
+ this.frameLine(
271
+ this.fg("dim", "↑↓ navigate · / filter · Enter select · Esc cancel"),
272
+ innerWidth,
273
+ ),
274
+ );
275
+ lines.push(this.borderLine(innerWidth, "bottom"));
276
+
277
+ return lines;
278
+ }
279
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * @pi-unipi/image — Settings dialog
3
+ *
4
+ * Simple `ctx.ui.select` loop, matching the web-api settings pattern. The
5
+ * model pickers are richer overlays (see model-selector.ts).
6
+ */
7
+
8
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
+
10
+ import {
11
+ DEFAULT_CONFIG,
12
+ DEFAULT_RECOGNIZE_SYSTEM_PROMPT,
13
+ getOutputDir,
14
+ loadConfig,
15
+ saveConfig,
16
+ type ImageConfig,
17
+ } from "../settings.js";
18
+ import {
19
+ formatModelRef,
20
+ listImageGenModels,
21
+ listVisionModels,
22
+ type ChatModelRegistry,
23
+ } from "../models.js";
24
+ import { ImageModelSelectorOverlay, type SelectableModel } from "./model-selector.js";
25
+
26
+ const EXIT = "__exit__";
27
+
28
+ function describe(config: ImageConfig): Array<{ value: string; label: string }> {
29
+ return [
30
+ {
31
+ value: "__gen_model__",
32
+ label: `🎨 Generation model — ${config.generate.model || "(none)"}`,
33
+ },
34
+ {
35
+ value: "__gen_enabled__",
36
+ label: ` image_generate tool — ${config.generate.enabled ? "enabled" : "disabled"}`,
37
+ },
38
+ {
39
+ value: "__gen_save__",
40
+ label: ` Save to disk — ${config.generate.saveToDisk ? "on" : "off"}`,
41
+ },
42
+ {
43
+ value: "__gen_dir__",
44
+ label: ` Output directory — ${config.generate.outputDir}`,
45
+ },
46
+ {
47
+ value: "__rec_model__",
48
+ label: `👁 Recognition model — ${config.recognize.model || "(session model)"}`,
49
+ },
50
+ {
51
+ value: "__rec_enabled__",
52
+ label: ` image_recognize tool — ${config.recognize.enabled ? "enabled" : "disabled"}`,
53
+ },
54
+ {
55
+ value: "__rec_prompt__",
56
+ label: ` System prompt — ${summarize(config.recognize.systemPrompt)}`,
57
+ },
58
+ { value: "__reset__", label: "↺ Reset to defaults" },
59
+ { value: EXIT, label: "← Back" },
60
+ ];
61
+ }
62
+
63
+ function summarize(text: string, max = 45): string {
64
+ const flat = text.replace(/\s+/g, " ").trim();
65
+ if (!flat) return "(empty)";
66
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`;
67
+ }
68
+
69
+ /** Show the settings dialog until the user backs out. */
70
+ export async function showSettingsDialog(ctx: ExtensionCommandContext): Promise<void> {
71
+ for (;;) {
72
+ const config = loadConfig();
73
+ const options = describe(config);
74
+ const labels = options.map((o) => o.label);
75
+
76
+ const picked = await ctx.ui.select("Image Settings", labels);
77
+ if (!picked) return;
78
+
79
+ const choice = options.find((o) => o.label === picked)?.value;
80
+ if (!choice || choice === EXIT) return;
81
+
82
+ await applyChoice(ctx, choice, config);
83
+ }
84
+ }
85
+
86
+ async function applyChoice(
87
+ ctx: ExtensionCommandContext,
88
+ choice: string,
89
+ config: ImageConfig,
90
+ ): Promise<void> {
91
+ switch (choice) {
92
+ case "__gen_enabled__":
93
+ config.generate.enabled = !config.generate.enabled;
94
+ saveConfig(config);
95
+ ctx.ui.notify(
96
+ `image_generate ${config.generate.enabled ? "enabled" : "disabled"} — restart the session to apply.`,
97
+ "info",
98
+ );
99
+ return;
100
+
101
+ case "__rec_enabled__":
102
+ config.recognize.enabled = !config.recognize.enabled;
103
+ saveConfig(config);
104
+ ctx.ui.notify(
105
+ `image_recognize ${config.recognize.enabled ? "enabled" : "disabled"} — restart the session to apply.`,
106
+ "info",
107
+ );
108
+ return;
109
+
110
+ case "__gen_save__":
111
+ config.generate.saveToDisk = !config.generate.saveToDisk;
112
+ saveConfig(config);
113
+ ctx.ui.notify(
114
+ config.generate.saveToDisk
115
+ ? `Generated images will be saved to ${getOutputDir(config)}`
116
+ : "Generated images will be returned inline only.",
117
+ "info",
118
+ );
119
+ return;
120
+
121
+ case "__gen_dir__": {
122
+ const value = await ctx.ui.input(
123
+ "Output directory for generated images",
124
+ config.generate.outputDir,
125
+ );
126
+ if (value?.trim()) {
127
+ config.generate.outputDir = value.trim();
128
+ saveConfig(config);
129
+ ctx.ui.notify(`Output directory set to ${getOutputDir(config)}`, "info");
130
+ }
131
+ return;
132
+ }
133
+
134
+ case "__rec_prompt__": {
135
+ const value = await ctx.ui.input(
136
+ "System prompt for image recognition (blank to restore the default)",
137
+ config.recognize.systemPrompt,
138
+ );
139
+ if (value === undefined) return;
140
+ config.recognize.systemPrompt = value.trim() || DEFAULT_RECOGNIZE_SYSTEM_PROMPT;
141
+ saveConfig(config);
142
+ ctx.ui.notify("System prompt updated.", "info");
143
+ return;
144
+ }
145
+
146
+ case "__gen_model__":
147
+ await pickModel(ctx, "generate", config);
148
+ return;
149
+
150
+ case "__rec_model__":
151
+ await pickModel(ctx, "recognize", config);
152
+ return;
153
+
154
+ case "__reset__":
155
+ saveConfig(structuredClone(DEFAULT_CONFIG));
156
+ ctx.ui.notify("Image settings reset to defaults.", "info");
157
+ return;
158
+ }
159
+ }
160
+
161
+ async function pickModel(
162
+ ctx: ExtensionCommandContext,
163
+ kind: "generate" | "recognize",
164
+ config: ImageConfig,
165
+ ): Promise<void> {
166
+ const models = await collectModels(ctx, kind);
167
+
168
+ if (models.length === 0) {
169
+ ctx.ui.notify(
170
+ kind === "generate"
171
+ ? "No image models available. Image generation is served through OpenRouter — add a key with /login."
172
+ : "No vision-capable models configured. Add a model that accepts image input.",
173
+ "warning",
174
+ );
175
+ return;
176
+ }
177
+
178
+ if (!ctx.hasUI) {
179
+ ctx.ui.notify("Model selection requires an interactive UI.", "warning");
180
+ return;
181
+ }
182
+
183
+ const current =
184
+ kind === "generate" ? config.generate.model : config.recognize.model;
185
+
186
+ await new Promise<void>((resolve) => {
187
+ ctx.ui.custom(
188
+ (tui, theme, _keybindings, done) => {
189
+ const overlay = new ImageModelSelectorOverlay(kind, models, current);
190
+ overlay.setTheme(theme);
191
+ overlay.onSelect = (modelRef) => {
192
+ const next = loadConfig();
193
+ if (kind === "generate") next.generate.model = modelRef;
194
+ else next.recognize.model = modelRef;
195
+ saveConfig(next);
196
+ };
197
+ overlay.onClose = () => done(undefined);
198
+ return {
199
+ render: (width: number) => overlay.render(width),
200
+ invalidate: () => overlay.invalidate(),
201
+ handleInput: (data: string) => {
202
+ overlay.handleInput(data);
203
+ tui.requestRender();
204
+ },
205
+ };
206
+ },
207
+ {
208
+ overlay: true,
209
+ overlayOptions: { width: "80%", minWidth: 50, anchor: "center", margin: 2 },
210
+ },
211
+ );
212
+ resolve();
213
+ });
214
+ }
215
+
216
+ async function collectModels(
217
+ ctx: ExtensionCommandContext,
218
+ kind: "generate" | "recognize",
219
+ ): Promise<SelectableModel[]> {
220
+ if (kind === "generate") {
221
+ const models = await listImageGenModels();
222
+ return models.map((m) => ({ provider: m.provider, id: m.id, name: m.name }));
223
+ }
224
+
225
+ const registry = (ctx as unknown as { modelRegistry?: ChatModelRegistry })
226
+ .modelRegistry;
227
+ if (!registry) return [];
228
+
229
+ return listVisionModels(registry).map((m) => ({
230
+ provider: m.provider,
231
+ id: m.id,
232
+ name: m.name,
233
+ }));
234
+ }
235
+
236
+ export { formatModelRef };