@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.
- package/CHANGELOG.md +99 -0
- package/LICENSE +0 -0
- package/README.md +137 -129
- package/package.json +72 -68
- package/src/dataloader.ts +0 -0
- package/src/describer.ts +0 -0
- package/src/dispose.ts +0 -0
- package/src/error-log.ts +0 -0
- package/src/image.ts +0 -0
- package/src/index.ts +0 -0
- package/src/prewarm-editor.ts +0 -0
- package/src/usage.ts +0 -0
- package/src/vision-model-selector.ts +690 -600
- package/vision-watcher.ts +1304 -1304
- package/vitest.config.ts +0 -0
|
@@ -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 (
|
|
7
|
-
* - A leading "None" row clears the configured vision model
|
|
8
|
-
* - Space selects the highlighted model as the primary describer (toggle)
|
|
9
|
-
* - Ctrl+
|
|
10
|
-
* - Ctrl+
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
Text,
|
|
25
|
-
truncateToWidth,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
import type {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
* survives Windows conhost/Windows Terminal
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
*
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
private
|
|
121
|
-
|
|
122
|
-
private
|
|
123
|
-
|
|
124
|
-
private
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
this.
|
|
165
|
-
|
|
166
|
-
this.
|
|
167
|
-
|
|
168
|
-
this.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
lines
|
|
193
|
-
lines.push(...this.
|
|
194
|
-
lines.push(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
"[
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
this.
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
+
}
|