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