@bismawy/pi-vision-watcher 1.0.13 → 1.0.15

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.
@@ -1,601 +1,626 @@
1
- /**
2
- * VisionModelSelectorComponent — an interactive TUI for choosing which model
3
- * describes images during vision handoff.
4
- *
5
- * Uses the same patterns as pi's built-in selectors and pi-hide-providers:
6
- * - Lists connected (authenticated) models, vision-capable ones first (👀 badge)
7
- * - A leading "None" row clears the configured vision model
8
- * - Space selects the highlighted model as the primary describer (toggle)
9
- * - Ctrl+Alt+F toggles the highlighted model in/out of the failover chain (max 3)
10
- * - Ctrl+T walks the thinking ladder, Ctrl+A toggles async paste handoff
11
- * - Enter or Ctrl+S saves, Esc / Ctrl+C cancels
12
- * - The primary is marked ✓, chain members 🔁
13
- */
14
-
15
- import {
16
- Container,
17
- type Component,
18
- fuzzyFilter,
19
- getKeybindings,
20
- Input,
21
- Key,
22
- matchesKey,
23
- Spacer,
24
- Text,
25
- truncateToWidth,
26
- wrapTextWithAnsi,
27
- } from "@earendil-works/pi-tui";
28
- import type { Theme } from "@earendil-works/pi-coding-agent";
29
- import type { ThinkingLevel } from "@earendil-works/pi-ai";
30
- import { DynamicBorder, keyText } from "@earendil-works/pi-coding-agent";
31
- import { formatModelRef, isVisionModel, THINKING_LEVELS } from "./index.js";
32
-
33
- /** Failover chain length cap — three is already far past the point where a
34
- * fourth describer would ever be reached. */
35
- export const MAX_FALLBACKS = 3;
36
-
37
- /** Key that toggles failover-chain membership, and the hint shown for it.
38
- *
39
- * Deliberately a single control byte nobody else wants: `ctrl+alt+f` never
40
- * survives Windows conhost/Windows Terminal (AltGr handling drops or downgrades
41
- * it), `alt+f` is pi's editor word-right, and `ctrl+f` is pi's find-text. */
42
- const FALLBACK_KEY = Key.ctrl("q");
43
- const FALLBACK_KEY_HINT = "ctrl+q";
44
- const RESET_FALLBACKS_KEY = Key.ctrlShift("q");
45
- const RESET_FALLBACKS_KEY_HINT = "ctrl+shift+q";
46
-
47
- /** Provider ids that don't title-case cleanly. Everything else falls back to
48
- * word-capitalisation (`custom-openrouter-ai` → "Custom Openrouter AI"). */
49
- const PROVIDER_LABELS: Record<string, string> = {
50
- openai: "OpenAI",
51
- "openai-codex": "OpenAI Codex",
52
- anthropic: "Anthropic",
53
- xai: "xAI",
54
- github: "GitHub",
55
- huggingface: "Hugging Face",
56
- vertex: "Vertex AI",
57
- };
58
-
59
- const ACRONYMS = new Set(["ai", "api", "gpt", "llm", "mcp", "cli", "glm"]);
60
-
61
- /** Human-readable provider name for the detail pane. */
62
- export function providerLabel(provider: string): string {
63
- const known = PROVIDER_LABELS[provider];
64
- if (known) return known;
65
- return provider
66
- .split(/[-_]/)
67
- .filter(Boolean)
68
- .map((word) =>
69
- ACRONYMS.has(word.toLowerCase())
70
- ? word.toUpperCase()
71
- : word.charAt(0).toUpperCase() + word.slice(1),
72
- )
73
- .join(" ");
74
- }
75
-
76
- interface DisplayItem {
77
- /** "provider/id", or null for the synthetic "None" row. */
78
- ref: string | null;
79
- provider: string;
80
- modelId: string;
81
- modelName: string;
82
- vision: boolean;
83
- /** Whether the model declares reasoning (thinking) support. */
84
- reasoning: boolean;
85
- none?: boolean;
86
- }
87
-
88
- export interface VisionModelSelectorResult {
89
- /** The selected "provider/id", or null if the user picked "None" / cancelled. */
90
- ref: string | null;
91
- /** True if the user cancelled (esc) — config should not change. */
92
- cancelled: boolean;
93
- /** Thinking on/off chosen in the picker. */
94
- thinking: boolean;
95
- /** Thinking effort chosen in the picker. */
96
- thinkingLevel: ThinkingLevel;
97
- /** Whether pasted paths should be injected if no matching read wins. */
98
- asyncClipboardHandoff: boolean;
99
- /** The failover chain, in list order. Enter/ctrl+s saves it together with
100
- * the primary selection — the picker edits both in one screen. */
101
- fallbackModels: string[];
102
- }
103
-
104
- export class VisionModelSelectorComponent implements Component {
105
- private theme: Theme;
106
- private done: (result: VisionModelSelectorResult) => void;
107
-
108
- private allItems: DisplayItem[];
109
- private filteredItems: DisplayItem[];
110
- private selectedIndex = 0;
111
- private readonly maxVisible = 10;
112
- private searchInput: Input;
113
- private listContainer: Container;
114
- private footerText: Text;
115
-
116
- private currentRef: string | null;
117
- private thinking: boolean;
118
- private thinkingLevel: ThinkingLevel;
119
- private asyncClipboardHandoff: boolean;
120
- /** Fallback-chain membership, toggled in-place with {@link FALLBACK_KEY}. */
121
- private fallbacks: Set<string>;
122
- /** Transient hint (e.g. chain cap hit), rendered in the detail pane. */
123
- private notice: string | null = null;
124
-
125
- private _focused = false;
126
- get focused(): boolean {
127
- return this._focused;
128
- }
129
- set focused(value: boolean) {
130
- this._focused = value;
131
- this.searchInput.focused = value;
132
- }
133
-
134
- constructor(
135
- theme: Theme,
136
- allModels: Array<{
137
- provider: string;
138
- id: string;
139
- name: string;
140
- input?: ("text" | "image")[];
141
- reasoning?: boolean;
142
- }>,
143
- currentRef: string | null,
144
- currentThinking: boolean,
145
- currentThinkingLevel: ThinkingLevel,
146
- currentAsyncClipboardHandoff: boolean,
147
- done: (result: VisionModelSelectorResult) => void,
148
- currentFallbacks: string[] = [],
149
- ) {
150
- this.theme = theme;
151
- this.done = done;
152
- this.currentRef = currentRef;
153
- this.thinking = currentThinking;
154
- this.thinkingLevel = currentThinkingLevel;
155
- this.asyncClipboardHandoff = currentAsyncClipboardHandoff;
156
- this.fallbacks = new Set(currentFallbacks);
157
- this.allItems = this.buildItems(allModels);
158
- this.filteredItems = this.allItems;
159
-
160
- const startIdx = this.allItems.findIndex((i) => i.ref === currentRef);
161
- this.selectedIndex = startIdx >= 0 ? startIdx : 0;
162
-
163
- this.searchInput = new Input();
164
- this.listContainer = new Container();
165
- this.footerText = new Text(this.getFooterText(), 0, 0);
166
-
167
- this.searchInput.onSubmit = () => this.save();
168
-
169
- this.updateList();
170
- }
171
-
172
- render(width: number): string[] {
173
- const lines: string[] = [];
174
- lines.push(...new DynamicBorder((s) => this.theme.fg("accent", s)).render(width));
175
- lines.push("");
176
- lines.push(
177
- truncateToWidth(
178
- this.theme.fg("accent", this.theme.bold("Vision Watcher")),
179
- width,
180
- "",
181
- ),
182
- );
183
- lines.push(
184
- ...wrapTextWithAnsi(
185
- this.theme.fg(
186
- "muted",
187
- "Pick a vision-capable model to describe images for text-only models.",
188
- ),
189
- width,
190
- ),
191
- );
192
- lines.push("");
193
- lines.push(...this.searchInput.render(width));
194
- lines.push("");
195
- lines.push(...this.listContainer.render(width));
196
- lines.push("");
197
- lines.push(...this.footerText.render(width));
198
- lines.push(...new DynamicBorder((s) => this.theme.fg("accent", s)).render(width));
199
- return lines.map((line) => truncateToWidth(line, width, ""));
200
- }
201
-
202
- handleInput(data: string): void {
203
- const kb = getKeybindings();
204
-
205
- if (kb.matches(data, "tui.select.up")) {
206
- if (this.filteredItems.length === 0) return;
207
- this.selectedIndex =
208
- this.selectedIndex === 0
209
- ? this.filteredItems.length - 1
210
- : this.selectedIndex - 1;
211
- this.updateList();
212
- return;
213
- }
214
-
215
- if (kb.matches(data, "tui.select.down")) {
216
- if (this.filteredItems.length === 0) return;
217
- this.selectedIndex =
218
- this.selectedIndex === this.filteredItems.length - 1
219
- ? 0
220
- : this.selectedIndex + 1;
221
- this.updateList();
222
- return;
223
- }
224
-
225
- if (kb.matches(data, "tui.select.confirm") || matchesKey(data, Key.ctrl("s"))) {
226
- this.save();
227
- return;
228
- }
229
-
230
- if (matchesKey(data, Key.escape)) {
231
- this.finish(true);
232
- return;
233
- }
234
-
235
- if (matchesKey(data, Key.ctrl("c"))) {
236
- if (this.searchInput.getValue()) {
237
- this.searchInput.setValue("");
238
- this.refresh();
239
- } else {
240
- this.finish(true);
241
- }
242
- return;
243
- }
244
-
245
- // Space selects the highlighted model as the primary describer; pressing it
246
- // again on the same model clears it back to "None". While a filter query is
247
- // present, space is left to the search input so multi-word queries like
248
- // "gemini 3.8" stay typeable.
249
- if ((data === " " || matchesKey(data, Key.space)) && !this.searchInput.getValue()) {
250
- const item = this.filteredItems[this.selectedIndex];
251
- if (item) this.selectPrimary(item.ref);
252
- return;
253
- }
254
-
255
- // Toggles the highlighted model in/out of the failover chain — a per-row
256
- // flag rather than a separate screen, so the primary and the chain are
257
- // chosen together. Intercepted before the search input (like the other ctrl
258
- // shortcuts) so the key never lands in the filter text. See
259
- // {@link FALLBACK_KEY} for why it isn't ctrl+f.
260
- if (matchesKey(data, RESET_FALLBACKS_KEY)) {
261
- this.clearFallbacks();
262
- return;
263
- }
264
-
265
- if (matchesKey(data, FALLBACK_KEY)) {
266
- const item = this.filteredItems[this.selectedIndex];
267
- if (item?.ref) this.toggleFallback(item.ref);
268
- return;
269
- }
270
-
271
- if (matchesKey(data, Key.ctrl("a"))) {
272
- this.asyncClipboardHandoff = !this.asyncClipboardHandoff;
273
- this.updateList();
274
- return;
275
- }
276
-
277
- // ctrl+t walks the whole thinking ladder (off → minimal → … → max → off) so
278
- // one key covers on/off *and* effort — no separate shift+tab binding.
279
- // Intercepted before the search input so it never lands in the filter text.
280
- if (kb.matches(data, "app.thinking.toggle") || matchesKey(data, Key.ctrl("t"))) {
281
- this.cycleThinking();
282
- this.updateList();
283
- return;
284
- }
285
-
286
- this.searchInput.handleInput(data);
287
- this.refresh();
288
- }
289
-
290
- invalidate(): void {
291
- this.searchInput.invalidate();
292
- this.listContainer.invalidate();
293
- this.footerText.invalidate();
294
- }
295
-
296
- // Internal helpers
297
-
298
- private buildItems(
299
- allModels: Array<{
300
- provider: string;
301
- id: string;
302
- name: string;
303
- input?: ("text" | "image")[];
304
- reasoning?: boolean;
305
- }>,
306
- ): DisplayItem[] {
307
- const items: DisplayItem[] = [
308
- {
309
- ref: null,
310
- provider: "",
311
- modelId: "none",
312
- modelName: "None — disable vision handoff",
313
- vision: false,
314
- reasoning: false,
315
- none: true,
316
- },
317
- ];
318
-
319
- const make = (m: {
320
- provider: string;
321
- id: string;
322
- name: string;
323
- input?: ("text" | "image")[];
324
- reasoning?: boolean;
325
- }): DisplayItem => ({
326
- ref: formatModelRef(m.provider, m.id),
327
- provider: m.provider,
328
- modelId: m.id,
329
- modelName: m.name || m.id,
330
- vision: isVisionModel(m),
331
- reasoning: !!m.reasoning,
332
- });
333
-
334
- // Only vision-capable models are listed — a text-only model can't describe
335
- // images, so it would only produce "[Image: description unavailable]" errors.
336
- const visionModels = allModels.filter((m) => isVisionModel(m)).map(make);
337
- return [...items, ...visionModels];
338
- }
339
-
340
- private getFooterText(): string {
341
- const totalCount = this.allItems.length - 1; // exclude the None row
342
- const matches = this.searchInput.getValue()
343
- ? `${this.filteredItems.length - 1} matches.`
344
- : `total ${totalCount} models.`;
345
-
346
- // The current selection lives in the detail pane above, so the footer only
347
- // carries keys + the model count.
348
- const parts: string[] = [
349
- `${keyText("tui.select.confirm")} = done`,
350
- "space = vision models",
351
- `${FALLBACK_KEY_HINT} = fallback models`,
352
- `${RESET_FALLBACKS_KEY_HINT} = reset fallbacks models`,
353
- "ctrl+t = thinking",
354
- "ctrl+a = async fallback",
355
- "esc = cancel",
356
- matches,
357
- ];
358
-
359
- return this.theme.fg("dim", ` ${parts.join(" | ")}`);
360
- }
361
-
362
- private clearFallbacks(): void {
363
- if (this.fallbacks.size === 0) return;
364
- this.fallbacks.clear();
365
- this.notice = null;
366
- this.updateList();
367
- }
368
-
369
- /** Toggle a model's membership in the fallback chain, preserving list order
370
- * (the chain is tried in order, so the config array must be deterministic
371
- * rather than Set-iteration order). */
372
- private toggleFallback(ref: string): void {
373
- if (this.fallbacks.has(ref)) {
374
- this.fallbacks.delete(ref);
375
- this.notice = null;
376
- } else if (this.fallbacks.size >= MAX_FALLBACKS) {
377
- this.notice = `max ${MAX_FALLBACKS} fallbacks — remove one first (${FALLBACK_KEY_HINT})`;
378
- } else {
379
- this.fallbacks.add(ref);
380
- this.notice = null;
381
- }
382
- this.updateList();
383
- }
384
-
385
- /** Space toggles the primary describer; picking the current one again clears
386
- * it (same as the None row), so one key both sets and unsets. */
387
- private selectPrimary(ref: string | null): void {
388
- this.currentRef = this.currentRef === ref ? null : ref;
389
- this.notice = null;
390
- this.updateList();
391
- }
392
-
393
- /** Fallback refs in list order (models the picker didn't show — e.g. one that
394
- * is no longer resolvable — are appended so a config value can't be
395
- * silently dropped just by opening the picker). */
396
- private orderedFallbacks(): string[] {
397
- const shown = this.allItems
398
- .map((i) => i.ref)
399
- .filter((r): r is string => !!r && this.fallbacks.has(r));
400
- const unshown = [...this.fallbacks].filter((r) => !shown.includes(r));
401
- return [...shown, ...unshown];
402
- }
403
-
404
- private refresh(): void {
405
- const query = this.searchInput.getValue();
406
- this.filteredItems = query
407
- ? fuzzyFilter(
408
- this.allItems,
409
- query,
410
- (i) => `${i.provider} ${i.modelId} ${i.ref ?? "none"} ${i.modelName}`,
411
- )
412
- : this.allItems;
413
- this.selectedIndex = Math.min(
414
- this.selectedIndex,
415
- Math.max(0, this.filteredItems.length - 1),
416
- );
417
- this.updateList();
418
- }
419
-
420
- private updateList(): void {
421
- this.listContainer.clear();
422
-
423
- if (this.filteredItems.length === 0) {
424
- this.listContainer.addChild(
425
- new Text(this.theme.fg("muted", " No matching models"), 0, 0),
426
- );
427
- }
428
-
429
- const startIndex = Math.max(
430
- 0,
431
- Math.min(
432
- this.selectedIndex - Math.floor(this.maxVisible / 2),
433
- this.filteredItems.length - this.maxVisible,
434
- ),
435
- );
436
- const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
437
-
438
- for (let i = startIndex; i < endIndex; i++) {
439
- const item = this.filteredItems[i];
440
- if (!item) continue;
441
-
442
- const isSelected = i === this.selectedIndex;
443
- const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
444
-
445
- let label: string;
446
- if (item.none) {
447
- label = this.theme.fg("warning", item.modelName);
448
- } else {
449
- const labelled = isSelected
450
- ? this.theme.fg("accent", item.modelId)
451
- : item.modelId;
452
- const badge = item.vision ? this.theme.fg("success", " 👀") : this.theme.fg("muted", " ·");
453
- const providerBadge = this.theme.fg("muted", ` [${item.provider}]`);
454
- label = `${labelled}${providerBadge}${badge}`;
455
- }
456
-
457
- const current = item.ref === this.currentRef && item.ref !== null
458
- ? this.theme.fg("success", " ✓")
459
- : item.none && this.currentRef === null
460
- ? this.theme.fg("success", " ✓")
461
- : "";
462
- // Fallback marker — distinct from the primary's ✓ so a model can visibly
463
- // be both the primary and a fallback (Sonnet as primary, Gemini as the
464
- // chain behind it).
465
- const fallbackMark =
466
- item.ref && this.fallbacks.has(item.ref)
467
- ? this.theme.fg("warning", " 🔁")
468
- : "";
469
-
470
- this.listContainer.addChild(new Text(`${prefix}${label}${current}${fallbackMark}`, 0, 0));
471
- }
472
-
473
- if (startIndex > 0 || endIndex < this.filteredItems.length) {
474
- this.listContainer.addChild(
475
- new Text(
476
- this.theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`),
477
- 0, 0,
478
- ),
479
- );
480
- }
481
-
482
- this.renderDetail();
483
- this.footerText.setText(this.getFooterText());
484
- }
485
-
486
- private itemByRef(ref: string): DisplayItem | undefined {
487
- return this.allItems.find((i) => i.ref === ref);
488
- }
489
-
490
- /** "Gemini 3.8 Flash (Antigravity)", or the raw ref when it isn't in the
491
- * registry right now (stale config) so it stays visible instead of blank. */
492
- private refLabel(ref: string): string {
493
- const item = this.itemByRef(ref);
494
- if (!item) return ref;
495
- const provider = providerLabel(item.provider);
496
- // Model display names often already carry the vendor — "Gemini 3.8 Flash
497
- // (Antigravity)" would otherwise come out as "… (Antigravity) (Antigravity)".
498
- return item.modelName.toLowerCase().includes(provider.toLowerCase())
499
- ? item.modelName
500
- : `${item.modelName} (${provider})`;
501
- }
502
-
503
- /** The detail pane summarises the *configuration* (primary, failover chain
504
- * and toggles) rather than the highlighted row, so each space / ctrl+q
505
- * press shows exactly what will be saved. */
506
- private renderDetail(): void {
507
- const line = (label: string, value: string) =>
508
- this.listContainer.addChild(
509
- new Text(this.theme.fg("dim", ` ${label}`) + value, 0, 0),
510
- );
511
-
512
- this.listContainer.addChild(new Spacer(1));
513
- line(
514
- "Vision-capable (👀): ",
515
- this.currentRef
516
- ? this.refLabel(this.currentRef)
517
- : this.theme.fg("muted", "none — vision handoff disabled"),
518
- );
519
-
520
- const chain = this.orderedFallbacks();
521
- line(
522
- "Fallback (🔁): ",
523
- chain.length
524
- ? `${this.theme.fg("success", "on")} - ${chain.map((r) => this.refLabel(r)).join(", ")}`
525
- : this.theme.fg("muted", "off"),
526
- );
527
-
528
- line(
529
- "Thinking: ",
530
- this.thinking
531
- ? this.theme.fg("success", `on (${this.thinkingLevel})`)
532
- : this.theme.fg("muted", "off"),
533
- );
534
-
535
- line(
536
- "Async pasted-path fallback: ",
537
- this.asyncClipboardHandoff
538
- ? this.theme.fg("success", "on")
539
- : this.theme.fg("muted", "off"),
540
- );
541
-
542
- // The warning follows the *highlighted* row: it answers "what happens if I
543
- // pick this model", which is also how you'd notice it while browsing.
544
- const highlighted = this.filteredItems[this.selectedIndex];
545
- if (this.thinking && highlighted && !highlighted.none && !highlighted.reasoning) {
546
- this.listContainer.addChild(
547
- new Text(
548
- this.theme.fg(
549
- "warning",
550
- ` ⚠ ${highlighted.modelId} declares no reasoning — thinking will be ignored`,
551
- ),
552
- 0, 0,
553
- ),
554
- );
555
- }
556
-
557
- if (this.notice) {
558
- this.listContainer.addChild(
559
- new Text(this.theme.fg("warning", ` ${this.notice}`), 0, 0),
560
- );
561
- }
562
- }
563
-
564
- private save(): void {
565
- this.done({
566
- ref: this.currentRef,
567
- cancelled: false,
568
- thinking: this.thinking,
569
- thinkingLevel: this.thinkingLevel,
570
- asyncClipboardHandoff: this.asyncClipboardHandoff,
571
- fallbackModels: this.orderedFallbacks(),
572
- });
573
- }
574
-
575
- private finish(cancelled: boolean): void {
576
- this.done({
577
- ref: null,
578
- cancelled,
579
- thinking: this.thinking,
580
- thinkingLevel: this.thinkingLevel,
581
- asyncClipboardHandoff: this.asyncClipboardHandoff,
582
- fallbackModels: this.orderedFallbacks(),
583
- });
584
- }
585
-
586
- /** Walk the thinking ladder with one key: off → minimal → low → medium →
587
- * high → xhigh → max → off → minimal → …
588
- *
589
- * A single index over off + {@link THINKING_LEVELS} so the cycle always
590
- * advances. Keeping a separate "remembered level" while off turns the tail
591
- * into a two-position toggle once you reach max (off → max → off → max). */
592
- private cycleThinking(): void {
593
- const ladder = THINKING_LEVELS.length + 1; // position 0 = off
594
- const current = this.thinking
595
- ? THINKING_LEVELS.indexOf(this.thinkingLevel) + 1
596
- : 0;
597
- const next = (Math.max(current, 0) + 1) % ladder;
598
- this.thinking = next > 0;
599
- if (next > 0) this.thinkingLevel = THINKING_LEVELS[next - 1]!;
600
- }
601
- }
1
+ /**
2
+ * VisionModelSelectorComponent — an interactive TUI for choosing which model
3
+ * describes images during vision handoff.
4
+ *
5
+ * Uses the same patterns as pi's built-in selectors and pi-hide-providers:
6
+ * - Lists connected (authenticated) models, vision-capable ones first (👀 badge)
7
+ * - A leading "None" row clears the configured vision model
8
+ * - Space selects the highlighted model as the primary describer (toggle)
9
+ * - Ctrl+Alt+F toggles the highlighted model in/out of the failover chain (max 3)
10
+ * - Ctrl+T walks the thinking ladder, Ctrl+A toggles async paste handoff
11
+ * - Enter or Ctrl+S saves, Esc / Ctrl+C cancels
12
+ * - The primary is marked ✓, chain members 🔁
13
+ */
14
+
15
+ import {
16
+ Container,
17
+ type Component,
18
+ fuzzyFilter,
19
+ getKeybindings,
20
+ Input,
21
+ Key,
22
+ matchesKey,
23
+ Text,
24
+ truncateToWidth,
25
+ visibleWidth,
26
+ wrapTextWithAnsi,
27
+ } from "@earendil-works/pi-tui";
28
+ import type { Theme } from "@earendil-works/pi-coding-agent";
29
+ import type { ThinkingLevel } from "@earendil-works/pi-ai";
30
+ import { DynamicBorder, keyText } from "@earendil-works/pi-coding-agent";
31
+ import { formatModelRef, isVisionModel, THINKING_LEVELS } from "./index.js";
32
+
33
+ /** Failover chain length cap — three is already far past the point where a
34
+ * fourth describer would ever be reached. */
35
+ export const MAX_FALLBACKS = 3;
36
+
37
+ /** Key that toggles failover-chain membership, and the hint shown for it.
38
+ *
39
+ * Deliberately a single control byte nobody else wants: `ctrl+alt+f` never
40
+ * survives Windows conhost/Windows Terminal (AltGr handling drops or downgrades
41
+ * it), `alt+f` is pi's editor word-right, and `ctrl+f` is pi's find-text. */
42
+ const FALLBACK_KEY = Key.ctrl("q");
43
+ const FALLBACK_KEY_HINT = "ctrl+q";
44
+ const RESET_FALLBACKS_KEY = Key.ctrlShift("q");
45
+
46
+ /** Provider ids that don't title-case cleanly. Everything else falls back to
47
+ * word-capitalisation (`custom-openrouter-ai` → "Custom Openrouter AI"). */
48
+ const PROVIDER_LABELS: Record<string, string> = {
49
+ openai: "OpenAI",
50
+ "openai-codex": "OpenAI Codex",
51
+ anthropic: "Anthropic",
52
+ xai: "xAI",
53
+ github: "GitHub",
54
+ huggingface: "Hugging Face",
55
+ vertex: "Vertex AI",
56
+ };
57
+
58
+ const ACRONYMS = new Set(["ai", "api", "gpt", "llm", "mcp", "cli", "glm"]);
59
+
60
+ /** Human-readable provider name for the detail pane. */
61
+ export function providerLabel(provider: string): string {
62
+ const known = PROVIDER_LABELS[provider];
63
+ if (known) return known;
64
+ return provider
65
+ .split(/[-_]/)
66
+ .filter(Boolean)
67
+ .map((word) =>
68
+ ACRONYMS.has(word.toLowerCase())
69
+ ? word.toUpperCase()
70
+ : word.charAt(0).toUpperCase() + word.slice(1),
71
+ )
72
+ .join(" ");
73
+ }
74
+
75
+ interface DisplayItem {
76
+ /** "provider/id", or null for the synthetic "None" row. */
77
+ ref: string | null;
78
+ provider: string;
79
+ modelId: string;
80
+ modelName: string;
81
+ vision: boolean;
82
+ /** Whether the model declares reasoning (thinking) support. */
83
+ reasoning: boolean;
84
+ none?: boolean;
85
+ }
86
+
87
+ export interface VisionModelSelectorResult {
88
+ /** The selected "provider/id", or null if the user picked "None" / cancelled. */
89
+ ref: string | null;
90
+ /** True if the user cancelled (esc) — config should not change. */
91
+ cancelled: boolean;
92
+ /** Thinking on/off chosen in the picker. */
93
+ thinking: boolean;
94
+ /** Thinking effort chosen in the picker. */
95
+ thinkingLevel: ThinkingLevel;
96
+ /** Whether pasted paths should be injected if no matching read wins. */
97
+ asyncClipboardHandoff: boolean;
98
+ /** The failover chain, in list order. Enter/ctrl+s saves it together with
99
+ * the primary selection — the picker edits both in one screen. */
100
+ fallbackModels: string[];
101
+ }
102
+
103
+ export class VisionModelSelectorComponent implements Component {
104
+ private theme: Theme;
105
+ private done: (result: VisionModelSelectorResult) => void;
106
+
107
+ private allItems: DisplayItem[];
108
+ private filteredItems: DisplayItem[];
109
+ private selectedIndex = 0;
110
+ private readonly maxVisible = 10;
111
+ private searchInput: Input;
112
+ private listContainer: Container;
113
+ private footerText: Text;
114
+
115
+ private currentRef: string | null;
116
+ private thinking: boolean;
117
+ private thinkingLevel: ThinkingLevel;
118
+ private asyncClipboardHandoff: boolean;
119
+ /** Fallback-chain membership, toggled in-place with {@link FALLBACK_KEY}. */
120
+ private fallbacks: Set<string>;
121
+ /** Transient hint (e.g. chain cap hit), rendered in the detail pane. */
122
+ private notice: string | null = null;
123
+
124
+ private _focused = false;
125
+ get focused(): boolean {
126
+ return this._focused;
127
+ }
128
+ set focused(value: boolean) {
129
+ this._focused = value;
130
+ this.searchInput.focused = value;
131
+ }
132
+
133
+ constructor(
134
+ theme: Theme,
135
+ allModels: Array<{
136
+ provider: string;
137
+ id: string;
138
+ name: string;
139
+ input?: ("text" | "image")[];
140
+ reasoning?: boolean;
141
+ }>,
142
+ currentRef: string | null,
143
+ currentThinking: boolean,
144
+ currentThinkingLevel: ThinkingLevel,
145
+ currentAsyncClipboardHandoff: boolean,
146
+ done: (result: VisionModelSelectorResult) => void,
147
+ currentFallbacks: string[] = [],
148
+ ) {
149
+ this.theme = theme;
150
+ this.done = done;
151
+ this.currentRef = currentRef;
152
+ this.thinking = currentThinking;
153
+ this.thinkingLevel = currentThinkingLevel;
154
+ this.asyncClipboardHandoff = currentAsyncClipboardHandoff;
155
+ this.fallbacks = new Set(currentFallbacks);
156
+ this.allItems = this.buildItems(allModels);
157
+ this.filteredItems = this.allItems;
158
+
159
+ const startIdx = this.allItems.findIndex((i) => i.ref === currentRef);
160
+ this.selectedIndex = startIdx >= 0 ? startIdx : 0;
161
+
162
+ // A bare `> ` gives no hint that this line filters the list, so the field
163
+ // carries an inline placeholder until something is typed.
164
+ this.searchInput = new Input({
165
+ placeholder: "type to filter models…",
166
+ placeholderStyle: (text) => this.theme.fg("muted", text),
167
+ });
168
+ this.listContainer = new Container();
169
+ this.footerText = new Text(this.getFooterText(), 0, 0);
170
+
171
+ this.searchInput.onSubmit = () => this.save();
172
+
173
+ this.updateList();
174
+ }
175
+
176
+ render(width: number): string[] {
177
+ const lines: string[] = [];
178
+ lines.push(...new DynamicBorder((s) => this.theme.fg("accent", s)).render(width));
179
+ lines.push(
180
+ truncateToWidth(
181
+ this.theme.fg("accent", this.theme.bold("Vision Watcher")),
182
+ width,
183
+ "",
184
+ ),
185
+ );
186
+ lines.push(
187
+ ...wrapTextWithAnsi(
188
+ this.theme.fg(
189
+ "muted",
190
+ "Pick a vision-capable model to describe images for text-only models.",
191
+ ),
192
+ width,
193
+ ),
194
+ );
195
+ lines.push("");
196
+ // Indented to the list's two-column gutter: at column 0 the field reads as
197
+ // a stray line rather than as the thing the list is filtered by.
198
+ lines.push(
199
+ ...this.searchInput
200
+ .render(Math.max(1, width - 2))
201
+ .map((line) => ` ${line}`),
202
+ );
203
+ lines.push("");
204
+ lines.push(...this.listContainer.render(width));
205
+ lines.push("");
206
+ lines.push(...this.detailLines(width));
207
+ lines.push("");
208
+ lines.push(...this.footerText.render(width));
209
+ lines.push(...new DynamicBorder((s) => this.theme.fg("accent", s)).render(width));
210
+ return lines.map((line) => truncateToWidth(line, width, ""));
211
+ }
212
+
213
+ handleInput(data: string): void {
214
+ const kb = getKeybindings();
215
+
216
+ if (kb.matches(data, "tui.select.up")) {
217
+ if (this.filteredItems.length === 0) return;
218
+ this.selectedIndex =
219
+ this.selectedIndex === 0
220
+ ? this.filteredItems.length - 1
221
+ : this.selectedIndex - 1;
222
+ this.updateList();
223
+ return;
224
+ }
225
+
226
+ if (kb.matches(data, "tui.select.down")) {
227
+ if (this.filteredItems.length === 0) return;
228
+ this.selectedIndex =
229
+ this.selectedIndex === this.filteredItems.length - 1
230
+ ? 0
231
+ : this.selectedIndex + 1;
232
+ this.updateList();
233
+ return;
234
+ }
235
+
236
+ if (kb.matches(data, "tui.select.confirm") || matchesKey(data, Key.ctrl("s"))) {
237
+ this.save();
238
+ return;
239
+ }
240
+
241
+ if (matchesKey(data, Key.escape)) {
242
+ this.finish(true);
243
+ return;
244
+ }
245
+
246
+ if (matchesKey(data, Key.ctrl("c"))) {
247
+ if (this.searchInput.getValue()) {
248
+ this.searchInput.setValue("");
249
+ this.refresh();
250
+ } else {
251
+ this.finish(true);
252
+ }
253
+ return;
254
+ }
255
+
256
+ // Space selects the highlighted model as the primary describer; pressing it
257
+ // again on the same model clears it back to "None". While a filter query is
258
+ // present, space is left to the search input so multi-word queries like
259
+ // "gemini 3.8" stay typeable.
260
+ if ((data === " " || matchesKey(data, Key.space)) && !this.searchInput.getValue()) {
261
+ const item = this.filteredItems[this.selectedIndex];
262
+ if (item) this.selectPrimary(item.ref);
263
+ return;
264
+ }
265
+
266
+ // Toggles the highlighted model in/out of the failover chain — a per-row
267
+ // flag rather than a separate screen, so the primary and the chain are
268
+ // chosen together. Intercepted before the search input (like the other ctrl
269
+ // shortcuts) so the key never lands in the filter text. See
270
+ // {@link FALLBACK_KEY} for why it isn't ctrl+f.
271
+ if (matchesKey(data, RESET_FALLBACKS_KEY)) {
272
+ this.clearFallbacks();
273
+ return;
274
+ }
275
+
276
+ if (matchesKey(data, FALLBACK_KEY)) {
277
+ const item = this.filteredItems[this.selectedIndex];
278
+ if (item?.ref) this.toggleFallback(item.ref);
279
+ return;
280
+ }
281
+
282
+ if (matchesKey(data, Key.ctrl("a"))) {
283
+ this.asyncClipboardHandoff = !this.asyncClipboardHandoff;
284
+ this.updateList();
285
+ return;
286
+ }
287
+
288
+ // ctrl+t walks the whole thinking ladder (off → minimal → … → max → off) so
289
+ // one key covers on/off *and* effort — no separate shift+tab binding.
290
+ // Intercepted before the search input so it never lands in the filter text.
291
+ if (kb.matches(data, "app.thinking.toggle") || matchesKey(data, Key.ctrl("t"))) {
292
+ this.cycleThinking();
293
+ this.updateList();
294
+ return;
295
+ }
296
+
297
+ this.searchInput.handleInput(data);
298
+ this.refresh();
299
+ }
300
+
301
+ invalidate(): void {
302
+ this.searchInput.invalidate();
303
+ this.listContainer.invalidate();
304
+ this.footerText.invalidate();
305
+ }
306
+
307
+ // Internal helpers
308
+
309
+ private buildItems(
310
+ allModels: Array<{
311
+ provider: string;
312
+ id: string;
313
+ name: string;
314
+ input?: ("text" | "image")[];
315
+ reasoning?: boolean;
316
+ }>,
317
+ ): DisplayItem[] {
318
+ const items: DisplayItem[] = [
319
+ {
320
+ ref: null,
321
+ provider: "",
322
+ modelId: "none",
323
+ modelName: "None — disable vision handoff",
324
+ vision: false,
325
+ reasoning: false,
326
+ none: true,
327
+ },
328
+ ];
329
+
330
+ const make = (m: {
331
+ provider: string;
332
+ id: string;
333
+ name: string;
334
+ input?: ("text" | "image")[];
335
+ reasoning?: boolean;
336
+ }): DisplayItem => ({
337
+ ref: formatModelRef(m.provider, m.id),
338
+ provider: m.provider,
339
+ modelId: m.id,
340
+ modelName: m.name || m.id,
341
+ vision: isVisionModel(m),
342
+ reasoning: !!m.reasoning,
343
+ });
344
+
345
+ // Only vision-capable models are listed — a text-only model can't describe
346
+ // images, so it would only produce "[Image: description unavailable]" errors.
347
+ const visionModels = allModels.filter((m) => isVisionModel(m)).map(make);
348
+ return [...items, ...visionModels];
349
+ }
350
+
351
+ private getFooterText(): string {
352
+ const totalCount = this.allItems.length - 1; // exclude the None row
353
+ const count = this.searchInput.getValue()
354
+ ? `${this.filteredItems.length - 1} matches`
355
+ : `${totalCount} models`;
356
+
357
+ // One legend line: the count carries the accent colour so it is the first
358
+ // thing the eye lands on, while the keys stay dim so they do not compete
359
+ // with the picker itself. ctrl+a toggles the async clipboard handoff; it
360
+ // is deliberately left out so this line stays readable at 80 columns.
361
+ const confirm = keyText("tui.select.confirm");
362
+ const legend = [
363
+ `[${confirm.charAt(0).toUpperCase()}${confirm.slice(1)}] Done`,
364
+ "[Space] Vision",
365
+ "[Ctrl+q] Fallback",
366
+ "[Ctrl+Shift+q] Reset",
367
+ "[Ctrl+t] Think",
368
+ "[Esc] Cancel",
369
+ ].join(" ");
370
+
371
+ return `${this.theme.fg("dim", " ")}${this.theme.fg("accent", count)}${this.theme.fg("dim", ` · ${legend}`)}`;
372
+ }
373
+
374
+ private clearFallbacks(): void {
375
+ if (this.fallbacks.size === 0) return;
376
+ this.fallbacks.clear();
377
+ this.notice = null;
378
+ this.updateList();
379
+ }
380
+
381
+ /** Toggle a model's membership in the fallback chain, preserving list order
382
+ * (the chain is tried in order, so the config array must be deterministic
383
+ * rather than Set-iteration order). */
384
+ private toggleFallback(ref: string): void {
385
+ if (this.fallbacks.has(ref)) {
386
+ this.fallbacks.delete(ref);
387
+ this.notice = null;
388
+ } else if (this.fallbacks.size >= MAX_FALLBACKS) {
389
+ this.notice = `max ${MAX_FALLBACKS} fallbacks — remove one first (${FALLBACK_KEY_HINT})`;
390
+ } else {
391
+ this.fallbacks.add(ref);
392
+ this.notice = null;
393
+ }
394
+ this.updateList();
395
+ }
396
+
397
+ /** Space toggles the primary describer; picking the current one again clears
398
+ * it (same as the None row), so one key both sets and unsets. */
399
+ private selectPrimary(ref: string | null): void {
400
+ this.currentRef = this.currentRef === ref ? null : ref;
401
+ this.notice = null;
402
+ this.updateList();
403
+ }
404
+
405
+ /** Fallback refs in list order (models the picker didn't show — e.g. one that
406
+ * is no longer resolvable — are appended so a config value can't be
407
+ * silently dropped just by opening the picker). */
408
+ private orderedFallbacks(): string[] {
409
+ const shown = this.allItems
410
+ .map((i) => i.ref)
411
+ .filter((r): r is string => !!r && this.fallbacks.has(r));
412
+ const unshown = [...this.fallbacks].filter((r) => !shown.includes(r));
413
+ return [...shown, ...unshown];
414
+ }
415
+
416
+ private refresh(): void {
417
+ const query = this.searchInput.getValue();
418
+ this.filteredItems = query
419
+ ? fuzzyFilter(
420
+ this.allItems,
421
+ query,
422
+ (i) => `${i.provider} ${i.modelId} ${i.ref ?? "none"} ${i.modelName}`,
423
+ )
424
+ : this.allItems;
425
+ this.selectedIndex = Math.min(
426
+ this.selectedIndex,
427
+ Math.max(0, this.filteredItems.length - 1),
428
+ );
429
+ this.updateList();
430
+ }
431
+
432
+ private updateList(): void {
433
+ this.listContainer.clear();
434
+
435
+ if (this.filteredItems.length === 0) {
436
+ this.listContainer.addChild(
437
+ new Text(this.theme.fg("muted", " No matching models"), 0, 0),
438
+ );
439
+ }
440
+
441
+ const startIndex = Math.max(
442
+ 0,
443
+ Math.min(
444
+ this.selectedIndex - Math.floor(this.maxVisible / 2),
445
+ this.filteredItems.length - this.maxVisible,
446
+ ),
447
+ );
448
+ const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
449
+
450
+ for (let i = startIndex; i < endIndex; i++) {
451
+ const item = this.filteredItems[i];
452
+ if (!item) continue;
453
+
454
+ const isSelected = i === this.selectedIndex;
455
+ const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
456
+
457
+ let label: string;
458
+ if (item.none) {
459
+ label = this.theme.fg("warning", item.modelName);
460
+ } else {
461
+ const labelled = isSelected
462
+ ? this.theme.fg("accent", item.modelId)
463
+ : item.modelId;
464
+ const badge = item.vision ? this.theme.fg("success", " 👀") : this.theme.fg("muted", " ·");
465
+ const providerBadge = this.theme.fg("muted", ` [${item.provider}]`);
466
+ label = `${labelled}${providerBadge}${badge}`;
467
+ }
468
+
469
+ const current = item.ref === this.currentRef && item.ref !== null
470
+ ? this.theme.fg("success", " ✓")
471
+ : item.none && this.currentRef === null
472
+ ? this.theme.fg("success", " ✓")
473
+ : "";
474
+ // Fallback marker — distinct from the primary's ✓ so a model can visibly
475
+ // be both the primary and a fallback (Sonnet as primary, Gemini as the
476
+ // chain behind it).
477
+ const fallbackMark =
478
+ item.ref && this.fallbacks.has(item.ref)
479
+ ? this.theme.fg("warning", " 🔁")
480
+ : "";
481
+
482
+ this.listContainer.addChild(new Text(`${prefix}${label}${current}${fallbackMark}`, 0, 0));
483
+ }
484
+
485
+ if (startIndex > 0 || endIndex < this.filteredItems.length) {
486
+ this.listContainer.addChild(
487
+ new Text(
488
+ this.theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`),
489
+ 0, 0,
490
+ ),
491
+ );
492
+ }
493
+
494
+ this.footerText.setText(this.getFooterText());
495
+ }
496
+
497
+ private itemByRef(ref: string): DisplayItem | undefined {
498
+ return this.allItems.find((i) => i.ref === ref);
499
+ }
500
+
501
+ /** "Gemini 3.8 Flash (Antigravity)", or the raw ref when it isn't in the
502
+ * registry right now (stale config) so it stays visible instead of blank. */
503
+ private refLabel(ref: string): string {
504
+ const item = this.itemByRef(ref);
505
+ if (!item) return ref;
506
+ const provider = providerLabel(item.provider);
507
+ // Model display names often already carry the vendor — "Gemini 3.8 Flash
508
+ // (Antigravity)" would otherwise come out as "… (Antigravity) (Antigravity)".
509
+ return item.modelName.toLowerCase().includes(provider.toLowerCase())
510
+ ? item.modelName
511
+ : `${item.modelName} (${provider})`;
512
+ }
513
+
514
+ /** The detail pane summarises the *configuration* (primary, failover chain
515
+ * and toggles) rather than the highlighted row, so each space / ctrl+q
516
+ * press shows exactly what will be saved.
517
+ *
518
+ * Built per frame rather than cached in a child component because the
519
+ * label/value split only pays off once the width is known: a long fallback
520
+ * chain then wraps under its own value instead of spilling to column 0. */
521
+ private detailLines(width: number): string[] {
522
+ const out: string[] = [];
523
+
524
+ const line = (label: string, value: string) => {
525
+ const indent = " ".repeat(2 + visibleWidth(label));
526
+ const wrapped = wrapTextWithAnsi(
527
+ value,
528
+ Math.max(8, width - visibleWidth(indent)),
529
+ );
530
+ out.push(`${this.theme.fg("dim", ` ${label}`)}${wrapped[0] ?? ""}`);
531
+ for (const extra of wrapped.slice(1)) out.push(indent + extra);
532
+ };
533
+
534
+ // Free-standing sentence (warning / transient notice), hanging-indented
535
+ // under its own `⚠`/first word.
536
+ const note = (text: string) => {
537
+ const indent = " ";
538
+ wrapTextWithAnsi(text, Math.max(8, width - indent.length)).forEach(
539
+ (part, i) => out.push(i === 0 ? part : indent + part),
540
+ );
541
+ };
542
+
543
+ line(
544
+ "Vision-capable (👀): ",
545
+ this.currentRef
546
+ ? this.refLabel(this.currentRef)
547
+ : this.theme.fg("muted", "none — vision handoff disabled"),
548
+ );
549
+
550
+ const chain = this.orderedFallbacks();
551
+ line(
552
+ "Fallback (🔁): ",
553
+ chain.length
554
+ ? `${this.theme.fg("success", "on")} - ${chain.map((r) => this.refLabel(r)).join(", ")}`
555
+ : this.theme.fg("muted", "off"),
556
+ );
557
+
558
+ line(
559
+ "Thinking: ",
560
+ this.thinking
561
+ ? this.theme.fg("success", `on (${this.thinkingLevel})`)
562
+ : this.theme.fg("muted", "off"),
563
+ );
564
+
565
+ line(
566
+ "Async pasted-path fallback: ",
567
+ this.asyncClipboardHandoff
568
+ ? this.theme.fg("success", "on")
569
+ : this.theme.fg("muted", "off"),
570
+ );
571
+
572
+ // The warning follows the *highlighted* row: it answers "what happens if I
573
+ // pick this model", which is also how you'd notice it while browsing.
574
+ const highlighted = this.filteredItems[this.selectedIndex];
575
+ if (this.thinking && highlighted && !highlighted.none && !highlighted.reasoning) {
576
+ note(
577
+ this.theme.fg(
578
+ "warning",
579
+ ` ⚠ ${highlighted.modelId} declares no reasoning — thinking will be ignored`,
580
+ ),
581
+ );
582
+ }
583
+
584
+ if (this.notice) note(this.theme.fg("warning", ` ${this.notice}`));
585
+
586
+ return out;
587
+ }
588
+
589
+ private save(): void {
590
+ this.done({
591
+ ref: this.currentRef,
592
+ cancelled: false,
593
+ thinking: this.thinking,
594
+ thinkingLevel: this.thinkingLevel,
595
+ asyncClipboardHandoff: this.asyncClipboardHandoff,
596
+ fallbackModels: this.orderedFallbacks(),
597
+ });
598
+ }
599
+
600
+ private finish(cancelled: boolean): void {
601
+ this.done({
602
+ ref: null,
603
+ cancelled,
604
+ thinking: this.thinking,
605
+ thinkingLevel: this.thinkingLevel,
606
+ asyncClipboardHandoff: this.asyncClipboardHandoff,
607
+ fallbackModels: this.orderedFallbacks(),
608
+ });
609
+ }
610
+
611
+ /** Walk the thinking ladder with one key: off → minimal → low → medium →
612
+ * high → xhigh → max → off → minimal → …
613
+ *
614
+ * A single index over off + {@link THINKING_LEVELS} so the cycle always
615
+ * advances. Keeping a separate "remembered level" while off turns the tail
616
+ * into a two-position toggle once you reach max (off → max → off → max). */
617
+ private cycleThinking(): void {
618
+ const ladder = THINKING_LEVELS.length + 1; // position 0 = off
619
+ const current = this.thinking
620
+ ? THINKING_LEVELS.indexOf(this.thinkingLevel) + 1
621
+ : 0;
622
+ const next = (Math.max(current, 0) + 1) % ladder;
623
+ this.thinking = next > 0;
624
+ if (next > 0) this.thinkingLevel = THINKING_LEVELS[next - 1]!;
625
+ }
626
+ }