@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.
- package/CHANGELOG.md +59 -0
- package/LICENSE +0 -0
- package/README.md +64 -56
- package/package.json +5 -2
- 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 +626 -601
- package/vision-watcher.ts +0 -0
- package/vitest.config.ts +0 -0
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
openai: "OpenAI",
|
|
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
|
-
private
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
private
|
|
109
|
-
private
|
|
110
|
-
private
|
|
111
|
-
private
|
|
112
|
-
private
|
|
113
|
-
private
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
private
|
|
117
|
-
private
|
|
118
|
-
private
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
this.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
this.
|
|
151
|
-
this.
|
|
152
|
-
this.
|
|
153
|
-
this.
|
|
154
|
-
this.
|
|
155
|
-
this.
|
|
156
|
-
this.
|
|
157
|
-
this.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
this.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
this.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
lines.push(
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
lines.push(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
this.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return;
|
|
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
|
-
if (matchesKey(data,
|
|
261
|
-
this.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (matchesKey(data,
|
|
272
|
-
this.
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
this.
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
this.
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
)
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
return
|
|
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
|
-
this.
|
|
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
|
-
|
|
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
|
+
}
|