@fanchaozz/provider-manager 1.0.0 → 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/README.md +267 -257
- package/README_EN.md +249 -249
- package/commands.ts +3 -17
- package/components.ts +1193 -866
- package/forms.ts +19 -1
- package/package.json +1 -1
- package/ui.ts +1245 -761
package/ui.ts
CHANGED
|
@@ -1,761 +1,1245 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ui.ts — TUI Dashboard
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
private
|
|
236
|
-
private
|
|
237
|
-
private
|
|
238
|
-
private
|
|
239
|
-
|
|
240
|
-
private
|
|
241
|
-
|
|
242
|
-
private
|
|
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
|
-
if (
|
|
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
|
-
this.
|
|
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
|
-
if (
|
|
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
|
-
void
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
private
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1
|
+
/**
|
|
2
|
+
* ui.ts — TUI Dashboard (overlay window)
|
|
3
|
+
*
|
|
4
|
+
* 浮窗式 3 区固定布局(不占对话窗口):
|
|
5
|
+
* ┌─────────────────────── /─────────── /──────────────────────┐
|
|
6
|
+
* │ Providers 2·2✓ 6m │ Models (kdapi) 2m · 1R · 1I│ <- 顶区(左/右两列)
|
|
7
|
+
* │ ▸ kdapi ✓ 2m │ ▸ minimax-m3 [RI] 1.0Mc 128km │
|
|
8
|
+
* │ agnes ✓ 4m │ minimax-m2 [--] 256kc 32km │
|
|
9
|
+
* │ ⋮ 0 more │ ⋮ 0 more │
|
|
10
|
+
* │ (1/2) │ (1/2) │
|
|
11
|
+
* ├───────────────────────┴────────────────────────────────────┤
|
|
12
|
+
* │ Detail: provider or model info │ <- 底区
|
|
13
|
+
* │ ... │
|
|
14
|
+
* ├────────────────────────────────────────────────────────────┤
|
|
15
|
+
* │ ↑↓ nav · ←→ pane · n new · Enter edit · y sync · ? help │ <- footer(≤2 行)
|
|
16
|
+
* └────────────────────────────────────────────────────────────┘
|
|
17
|
+
*
|
|
18
|
+
* 关键约束(按用户要求):
|
|
19
|
+
* - 3 个主要区域都使用固定可视行数(PROVIDER_VIEW_ROWS / MODEL_VIEW_ROWS = 8),数据多时滚动。
|
|
20
|
+
* - 滚动时显示 (current/total);与 ModelChecklist 一致。
|
|
21
|
+
* - footer 限制在 2 行内(hint 太长就加 "… [+N]" 截断),永远不撑爆宽度。
|
|
22
|
+
* - 整个浮窗通过 ctx.ui.custom({ overlay: true }) 打开;表单子流程同样以 overlay 形式打开。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
27
|
+
import { readModelsJson, getModelsJsonPath, maskApiKey, type ModelsJson, type ProviderConfig, type ModelConfig } from "./store.ts";
|
|
28
|
+
import {
|
|
29
|
+
addProviderFlow,
|
|
30
|
+
addModelFlow,
|
|
31
|
+
editProviderFlow,
|
|
32
|
+
deleteProviderFlow,
|
|
33
|
+
editModelFlow,
|
|
34
|
+
deleteModelFlow,
|
|
35
|
+
syncFlow,
|
|
36
|
+
} from "./forms.ts";
|
|
37
|
+
import { testModel, testProvider, formatTestResult, type TestMode, type TestResult } from "./test.ts";
|
|
38
|
+
import { box, truncateForRender } from "./components.ts";
|
|
39
|
+
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// 类型
|
|
42
|
+
// ============================================================================
|
|
43
|
+
|
|
44
|
+
type ModelRow = {
|
|
45
|
+
id: string;
|
|
46
|
+
provider: string;
|
|
47
|
+
contextWindow?: number;
|
|
48
|
+
maxTokens?: number;
|
|
49
|
+
reasoning: boolean;
|
|
50
|
+
input: string[];
|
|
51
|
+
hasApiKey: boolean;
|
|
52
|
+
thinkingLevelMap?: Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max", string | null>>;
|
|
53
|
+
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
54
|
+
compat?: Record<string, unknown>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type ProviderRow = {
|
|
58
|
+
id: string;
|
|
59
|
+
displayName: string;
|
|
60
|
+
models: ModelRow[];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// 布局常量(fixed-height panes)
|
|
65
|
+
// ============================================================================
|
|
66
|
+
|
|
67
|
+
/** Provider 列可视行数(含 header + list + (current/total) 指示)。
|
|
68
|
+
* 列表区 = VIEW_ROWS - 1(header 占 1 行);超过则滚动并钉住首项。 */
|
|
69
|
+
export const PROVIDER_VIEW_ROWS = 8;
|
|
70
|
+
|
|
71
|
+
/** Model 列可视行数。逻辑同 PROVIDER_VIEW_ROWS。 */
|
|
72
|
+
export const MODEL_VIEW_ROWS = 8;
|
|
73
|
+
|
|
74
|
+
/** Detail 面板可视行数(含分隔 + header + 内容)。超过则滚动;底部 footer 不被顶掉。
|
|
75
|
+
* 12 行对大多数 provider 够用(Identity 4 + Endpoint 3 + Auth 4 = 11 + 间隔);model 详情更短。
|
|
76
|
+
* 实际取 16:覆盖典型 provider(Identity + Endpoint + Auth,~13 行)和 model(caps+limits+thinking,~10 行)。
|
|
77
|
+
* 仍有超出时截断底部 + "⋮ N more"。 */
|
|
78
|
+
export const DETAIL_VIEW_ROWS = 16;
|
|
79
|
+
|
|
80
|
+
/** Footer 允许的最多行数。hint 拼接后超过此值则加 "+N" 截断。 */
|
|
81
|
+
export const FOOTER_MAX_LINES = 2;
|
|
82
|
+
|
|
83
|
+
/** 浮窗最大宽度(terminal 宽度不足时会被 overlayOptions 折算)。 */
|
|
84
|
+
export const OVERLAY_MAX_WIDTH = 100;
|
|
85
|
+
|
|
86
|
+
/** 浮窗目标宽度。 */
|
|
87
|
+
export const OVERLAY_WIDTH = 96;
|
|
88
|
+
|
|
89
|
+
// ============================================================================
|
|
90
|
+
// 工具
|
|
91
|
+
// ============================================================================
|
|
92
|
+
|
|
93
|
+
/** 匹配 pi 风格的 key 字符串:escape / ctrl+c / up / down / enter / tab 等 */
|
|
94
|
+
function matchesKey(data: string, key: string): boolean {
|
|
95
|
+
const k = key.toLowerCase();
|
|
96
|
+
if (k.startsWith("ctrl+")) {
|
|
97
|
+
const ch = k.slice(5);
|
|
98
|
+
return data === `\x1b${ch}` || (ch.length === 1 && data === ch && data.charCodeAt(0) < 32);
|
|
99
|
+
}
|
|
100
|
+
switch (k) {
|
|
101
|
+
case "escape": return data === "\x1b" || data === "\x1b\x1b";
|
|
102
|
+
case "enter":
|
|
103
|
+
case "return": return data === "\r" || data === "\n";
|
|
104
|
+
case "tab": return data === "\t";
|
|
105
|
+
case "backspace": return data === "\x7f" || data === "\b";
|
|
106
|
+
case "up": return data === "\x1b[A" || data === "\x1bOA";
|
|
107
|
+
case "down": return data === "\x1b[B" || data === "\x1bOB";
|
|
108
|
+
case "left": return data === "\x1b[D" || data === "\x1bOD";
|
|
109
|
+
case "right": return data === "\x1b[C" || data === "\x1bOC";
|
|
110
|
+
case "home": return data === "\x1b[H" || data === "\x1bOH";
|
|
111
|
+
case "end": return data === "\x1b[F" || data === "\x1bOF";
|
|
112
|
+
case "pageup": return data === "\x1b[5~";
|
|
113
|
+
case "pagedown":return data === "\x1b[6~";
|
|
114
|
+
}
|
|
115
|
+
if (k.length === 1) return data === k;
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 按视觉宽度截断(中文算 2) */
|
|
120
|
+
function truncateToWidth(s: string, max: number, ellipsis = "…"): string {
|
|
121
|
+
if (max <= 0) return "";
|
|
122
|
+
let w = 0;
|
|
123
|
+
let out = "";
|
|
124
|
+
for (const ch of s) {
|
|
125
|
+
const cw = isWide(ch) ? 2 : 1;
|
|
126
|
+
if (w + cw > max) return out + (ellipsis && w + 1 <= max ? ellipsis : "");
|
|
127
|
+
out += ch;
|
|
128
|
+
w += cw;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isWide(ch: string): boolean {
|
|
134
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
135
|
+
return code > 0x1100 && (
|
|
136
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
137
|
+
(code >= 0x2e80 && code <= 0x9fff) ||
|
|
138
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
139
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
140
|
+
(code >= 0xffe0 && code <= 0xffe6)
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 主题感知的 pad:把 ANSI 和已知主题标签当作零宽,padding 补到目标可见宽度 */
|
|
145
|
+
function visiblePad(s: string, width: number): string {
|
|
146
|
+
const w = visibleWidthStrippingTheme(s);
|
|
147
|
+
if (w >= width) return s;
|
|
148
|
+
return s + " ".repeat(width - w);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const KNOWN_THEME_TAGS = new Set<string>([
|
|
152
|
+
"accent", "warning", "dim", "success", "error", "muted", "text",
|
|
153
|
+
"borderMuted", "border", "borderAccent",
|
|
154
|
+
"background", "primary", "secondary",
|
|
155
|
+
"toolTitle", "toolOutput", "toolBg",
|
|
156
|
+
"customMessageBg", "userMessageBg", "thinking",
|
|
157
|
+
"bold", "italic", "underline", "inverse",
|
|
158
|
+
"selection", "comment", "keyword", "string", "number", "function",
|
|
159
|
+
"variable", "type", "operator", "punctuation", "property",
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
function visibleWidthStrippingTheme(s: string): number {
|
|
163
|
+
let w = 0;
|
|
164
|
+
let i = 0;
|
|
165
|
+
while (i < s.length) {
|
|
166
|
+
if (s[i] === "\x1b" && i + 1 < s.length && s[i + 1] === "[") {
|
|
167
|
+
const close = s.indexOf("m", i + 2);
|
|
168
|
+
if (close !== -1) { i = close + 1; continue; }
|
|
169
|
+
const csiEnd = s.slice(i + 2).search(/[A-Za-z]/);
|
|
170
|
+
if (csiEnd !== -1) { i = i + 2 + csiEnd + 1; continue; }
|
|
171
|
+
}
|
|
172
|
+
if (s[i] === "[") {
|
|
173
|
+
const close = s.indexOf("]", i + 1);
|
|
174
|
+
if (close !== -1) {
|
|
175
|
+
const inner = s.slice(i + 1, close);
|
|
176
|
+
if (KNOWN_THEME_TAGS.has(inner) || (inner.startsWith("/") && KNOWN_THEME_TAGS.has(inner.slice(1)))) {
|
|
177
|
+
i = close + 1;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
w += isWide(s[i]!) ? 2 : 1;
|
|
183
|
+
i++;
|
|
184
|
+
}
|
|
185
|
+
return w;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function visualWidth(s: string): number {
|
|
189
|
+
let w = 0;
|
|
190
|
+
for (const ch of s) w += isWide(ch) ? 2 : 1;
|
|
191
|
+
return w;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ============================================================================
|
|
195
|
+
// 数据加载
|
|
196
|
+
// ============================================================================
|
|
197
|
+
|
|
198
|
+
function buildProviders(ctx: ExtensionCommandContext, json: ModelsJson): { providers: ProviderRow[]; auth: Map<string, { hasKey: boolean; source?: string }> } {
|
|
199
|
+
const customIds = Object.keys(json.providers).sort();
|
|
200
|
+
const auth = new Map<string, { hasKey: boolean; source?: string }>();
|
|
201
|
+
for (const pid of customIds) {
|
|
202
|
+
const apiKey = json.providers[pid]?.apiKey;
|
|
203
|
+
auth.set(pid, inspectApiKey(apiKey));
|
|
204
|
+
}
|
|
205
|
+
const providers: ProviderRow[] = customIds.map((pid) => {
|
|
206
|
+
const customModels = (json.providers[pid]?.models ?? []) as ModelConfig[];
|
|
207
|
+
return {
|
|
208
|
+
id: pid,
|
|
209
|
+
displayName: ctx.modelRegistry.getProviderDisplayName(pid) ?? pid,
|
|
210
|
+
models: customModels.map((m): ModelRow => ({
|
|
211
|
+
id: m.id,
|
|
212
|
+
provider: pid,
|
|
213
|
+
contextWindow: m.contextWindow,
|
|
214
|
+
maxTokens: m.maxTokens,
|
|
215
|
+
reasoning: !!m.reasoning,
|
|
216
|
+
input: m.input ?? ["text"],
|
|
217
|
+
hasApiKey: auth.get(pid)?.hasKey ?? false,
|
|
218
|
+
thinkingLevelMap: m.thinkingLevelMap,
|
|
219
|
+
cost: m.cost,
|
|
220
|
+
compat: m.compat,
|
|
221
|
+
})),
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
return { providers, auth };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ============================================================================
|
|
228
|
+
// Dashboard 组件
|
|
229
|
+
// ============================================================================
|
|
230
|
+
|
|
231
|
+
type Pane = "provider" | "model";
|
|
232
|
+
|
|
233
|
+
class Dashboard {
|
|
234
|
+
static __test = true;
|
|
235
|
+
private providers: ProviderRow[] = [];
|
|
236
|
+
private auth = new Map<string, { hasKey: boolean; source?: string }>();
|
|
237
|
+
private providerIndex = 0;
|
|
238
|
+
private modelIndex = 0;
|
|
239
|
+
/** provider 列的滚动 offset(顶部可见项在 all providers 中的索引) */
|
|
240
|
+
private providerTop = 0;
|
|
241
|
+
/** model 列的滚动 offset */
|
|
242
|
+
private modelTop = 0;
|
|
243
|
+
private pane: Pane = "provider";
|
|
244
|
+
private help = false;
|
|
245
|
+
private initError?: string;
|
|
246
|
+
private cachedWidth = -1;
|
|
247
|
+
private cachedLines: string[] = [];
|
|
248
|
+
private onClose: () => void;
|
|
249
|
+
private theme: any;
|
|
250
|
+
private ctx: ExtensionCommandContext;
|
|
251
|
+
private json: ModelsJson = { providers: {} };
|
|
252
|
+
/** providerViewRows / modelViewRows 可由构造器覆盖(用于测试窄宽度场景) */
|
|
253
|
+
private providerViewRows: number;
|
|
254
|
+
private modelViewRows: number;
|
|
255
|
+
private detailViewRows: number;
|
|
256
|
+
|
|
257
|
+
constructor(
|
|
258
|
+
ctx: ExtensionCommandContext,
|
|
259
|
+
theme: any,
|
|
260
|
+
onClose: () => void,
|
|
261
|
+
opts: { providerViewRows?: number; modelViewRows?: number; detailViewRows?: number } = {},
|
|
262
|
+
) {
|
|
263
|
+
this.ctx = ctx;
|
|
264
|
+
this.theme = theme;
|
|
265
|
+
this.onClose = onClose;
|
|
266
|
+
this.providerViewRows = opts.providerViewRows ?? PROVIDER_VIEW_ROWS;
|
|
267
|
+
this.modelViewRows = opts.modelViewRows ?? MODEL_VIEW_ROWS;
|
|
268
|
+
this.detailViewRows = opts.detailViewRows ?? DETAIL_VIEW_ROWS;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 同步初始化:首帧就有数据,避免 "(no providers found)" 闪烁 */
|
|
272
|
+
init(): void {
|
|
273
|
+
const path = getModelsJsonPath();
|
|
274
|
+
let json: ModelsJson = { providers: {} };
|
|
275
|
+
if (existsSync(path)) {
|
|
276
|
+
try {
|
|
277
|
+
const text = readFileSync(path, "utf8");
|
|
278
|
+
const parsed = JSON.parse(text);
|
|
279
|
+
if (parsed && typeof parsed === "object" && parsed.providers && typeof parsed.providers === "object") {
|
|
280
|
+
json = parsed as ModelsJson;
|
|
281
|
+
}
|
|
282
|
+
} catch (err) {
|
|
283
|
+
this.initError = `models.json 解析失败: ${err instanceof Error ? err.message : err}`;
|
|
284
|
+
json = { providers: {} };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
this.json = json;
|
|
288
|
+
this.initError = undefined;
|
|
289
|
+
const built = buildProviders(this.ctx, json);
|
|
290
|
+
this.providers = built.providers;
|
|
291
|
+
this.auth = built.auth;
|
|
292
|
+
if (this.providerIndex >= this.providers.length) this.providerIndex = Math.max(0, this.providers.length - 1);
|
|
293
|
+
const curModels = this.providers[this.providerIndex]?.models ?? [];
|
|
294
|
+
if (this.modelIndex >= curModels.length) this.modelIndex = Math.max(0, curModels.length - 1);
|
|
295
|
+
this.adjustProviderTop();
|
|
296
|
+
this.adjustModelTop();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
handleInput(data: string): void {
|
|
300
|
+
if (matchesKey(data, "escape") || data === "q") {
|
|
301
|
+
this.onClose();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (matchesKey(data, "left") || matchesKey(data, "right")) {
|
|
305
|
+
this.pane = this.pane === "provider" ? "model" : "provider";
|
|
306
|
+
// 切到 model pane 时重置 model top(避免上一个 provider 的滚动位置传过来)
|
|
307
|
+
if (this.pane === "model") this.modelTop = 0;
|
|
308
|
+
this.invalidate();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (data === "?") {
|
|
312
|
+
this.help = !this.help;
|
|
313
|
+
this.invalidate();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// 空列表下 "新增" 是唯一动作,必须在导航块之前判定(避免被吞)
|
|
317
|
+
if (data === "n") {
|
|
318
|
+
if (this.pane === "provider") {
|
|
319
|
+
void this.runForm(addProviderFlow);
|
|
320
|
+
} else {
|
|
321
|
+
const sel = this.providers[this.providerIndex];
|
|
322
|
+
if (sel) void this.runForm(addModelFlow, sel.id);
|
|
323
|
+
else this.ctx.ui.notify("No provider selected", "warning");
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// 滚动 page up/down:翻整页
|
|
328
|
+
if (matchesKey(data, "pageup")) {
|
|
329
|
+
this.pageJump(-1);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (matchesKey(data, "pagedown")) {
|
|
333
|
+
this.pageJump(+1);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const items = this.pane === "provider"
|
|
337
|
+
? this.providers
|
|
338
|
+
: (this.providers[this.providerIndex]?.models ?? []);
|
|
339
|
+
if (items.length === 0) {
|
|
340
|
+
// 空列表:什么都不做(n 已在上面处理)
|
|
341
|
+
} else if (matchesKey(data, "up") || data === "k") {
|
|
342
|
+
if (this.pane === "model") {
|
|
343
|
+
// model pane: 调 setIndex 走 adjustModelTop
|
|
344
|
+
this.setIndex(this.index() === 0 ? items.length - 1 : this.index() - 1);
|
|
345
|
+
} else {
|
|
346
|
+
// provider pane: 换 provider 时重置 model 状态
|
|
347
|
+
if (items.length > 0) {
|
|
348
|
+
const newProvIdx = this.providerIndex === 0 ? items.length - 1 : this.providerIndex - 1;
|
|
349
|
+
this.switchProvider(newProvIdx);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} else if (matchesKey(data, "down") || data === "j") {
|
|
353
|
+
if (this.pane === "model") {
|
|
354
|
+
this.setIndex((this.index() + 1) % items.length);
|
|
355
|
+
} else {
|
|
356
|
+
if (items.length > 0) {
|
|
357
|
+
const newProvIdx = (this.providerIndex + 1) % items.length;
|
|
358
|
+
this.switchProvider(newProvIdx);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
} else if (data === "g") {
|
|
362
|
+
if (this.pane === "model") {
|
|
363
|
+
this.setIndex(0);
|
|
364
|
+
} else {
|
|
365
|
+
this.switchProvider(0);
|
|
366
|
+
}
|
|
367
|
+
} else if (data === "G") {
|
|
368
|
+
if (this.pane === "model") {
|
|
369
|
+
this.setIndex(items.length - 1);
|
|
370
|
+
} else {
|
|
371
|
+
this.switchProvider(items.length - 1);
|
|
372
|
+
}
|
|
373
|
+
} else if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
|
|
374
|
+
if (this.pane === "provider" && this.providers[this.providerIndex]) {
|
|
375
|
+
const id = this.providers[this.providerIndex].id;
|
|
376
|
+
void this.runForm(editProviderFlow, id);
|
|
377
|
+
} else {
|
|
378
|
+
const prov = this.providers[this.providerIndex];
|
|
379
|
+
const m = prov?.models[this.modelIndex];
|
|
380
|
+
if (prov && m) void this.runForm(editModelFlow, prov.id, m.id);
|
|
381
|
+
}
|
|
382
|
+
} else if (data === "d") {
|
|
383
|
+
const prov = this.providers[this.providerIndex];
|
|
384
|
+
if (this.pane === "provider" && prov) {
|
|
385
|
+
void this.runForm(deleteProviderFlow, prov.id);
|
|
386
|
+
} else if (prov && prov.models[this.modelIndex]) {
|
|
387
|
+
const id = prov.models[this.modelIndex].id;
|
|
388
|
+
void this.runForm(deleteModelFlow, prov.id, id);
|
|
389
|
+
}
|
|
390
|
+
} else if (data === "y") {
|
|
391
|
+
const sel = this.providers[this.providerIndex];
|
|
392
|
+
if (sel) void this.runSync(sel.id);
|
|
393
|
+
else this.ctx.ui.notify("No provider selected", "warning");
|
|
394
|
+
} else if (data === "t" || data === "T") {
|
|
395
|
+
void this.runTest(data === "T");
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private index(): number {
|
|
400
|
+
return this.pane === "provider" ? this.providerIndex : this.modelIndex;
|
|
401
|
+
}
|
|
402
|
+
private setIndex(i: number): void {
|
|
403
|
+
if (this.pane === "provider") {
|
|
404
|
+
this.providerIndex = i;
|
|
405
|
+
this.adjustProviderTop();
|
|
406
|
+
} else {
|
|
407
|
+
this.modelIndex = i;
|
|
408
|
+
this.adjustModelTop();
|
|
409
|
+
}
|
|
410
|
+
this.invalidate();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** 把 providerTop 调整为让 providerIndex 在可视区内的合法值。
|
|
414
|
+
* listH 必须与 renderProviderColumn 一致:rows - 2(header + (current/total) 各占 1 行)。 */
|
|
415
|
+
private adjustProviderTop(): void {
|
|
416
|
+
const total = this.providers.length;
|
|
417
|
+
const rows = this.providerViewRows;
|
|
418
|
+
const listH = Math.max(1, rows - 2);
|
|
419
|
+
const needPin = total > listH && this.providerIndex >= listH;
|
|
420
|
+
const viewport = Math.max(1, listH - (needPin ? 1 : 0));
|
|
421
|
+
if (this.providerIndex < this.providerTop) this.providerTop = this.providerIndex;
|
|
422
|
+
if (this.providerIndex >= this.providerTop + viewport) this.providerTop = this.providerIndex - viewport + 1;
|
|
423
|
+
const maxTop = Math.max(0, total - viewport);
|
|
424
|
+
if (this.providerTop > maxTop) this.providerTop = maxTop;
|
|
425
|
+
if (this.providerTop < 0) this.providerTop = 0;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private adjustModelTop(): void {
|
|
429
|
+
const total = this.providers[this.providerIndex]?.models.length ?? 0;
|
|
430
|
+
const rows = this.modelViewRows;
|
|
431
|
+
const listH = Math.max(1, rows - 2);
|
|
432
|
+
const needPin = total > listH && this.modelIndex >= listH;
|
|
433
|
+
const viewport = Math.max(1, listH - (needPin ? 1 : 0));
|
|
434
|
+
if (this.modelIndex < this.modelTop) this.modelTop = this.modelIndex;
|
|
435
|
+
if (this.modelIndex >= this.modelTop + viewport) this.modelTop = this.modelIndex - viewport + 1;
|
|
436
|
+
const maxTop = Math.max(0, total - viewport);
|
|
437
|
+
if (this.modelTop > maxTop) this.modelTop = maxTop;
|
|
438
|
+
if (this.modelTop < 0) this.modelTop = 0;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** 整页翻页(PgUp / PgDn) */
|
|
442
|
+
private pageJump(direction: 1 | -1): void {
|
|
443
|
+
if (this.pane === "provider") {
|
|
444
|
+
const step = Math.max(1, this.providerViewRows - 1);
|
|
445
|
+
const total = this.providers.length;
|
|
446
|
+
if (total === 0) return;
|
|
447
|
+
const next = Math.max(0, Math.min(total - 1, this.providerIndex + direction * step));
|
|
448
|
+
this.setIndex(next);
|
|
449
|
+
} else {
|
|
450
|
+
const total = this.providers[this.providerIndex]?.models.length ?? 0;
|
|
451
|
+
if (total === 0) return;
|
|
452
|
+
const step = Math.max(1, this.modelViewRows - 1);
|
|
453
|
+
const next = Math.max(0, Math.min(total - 1, this.modelIndex + direction * step));
|
|
454
|
+
this.setIndex(next);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 切到某 provider 后,重置 model 索引到合法范围并调整滚动 */
|
|
459
|
+
private setProviderIndex(i: number): void {
|
|
460
|
+
this.switchProvider(i);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** 切换 provider:重置 modelIndex/modelTop 到该 provider 合法范围 */
|
|
464
|
+
private switchProvider(i: number): void {
|
|
465
|
+
this.providerIndex = Math.max(0, Math.min(this.providers.length - 1, i));
|
|
466
|
+
const mlen = this.providers[this.providerIndex]?.models.length ?? 0;
|
|
467
|
+
this.modelIndex = Math.max(0, Math.min(mlen - 1, 0));
|
|
468
|
+
this.modelTop = 0;
|
|
469
|
+
this.adjustProviderTop();
|
|
470
|
+
this.adjustModelTop();
|
|
471
|
+
this.invalidate();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** 重新从磁盘读 models.json 并刷新(保留选择,如果 provider 还在) */
|
|
475
|
+
private async invalidateAndReload(): Promise<void> {
|
|
476
|
+
const json = await readModelsJson();
|
|
477
|
+
this.json = json;
|
|
478
|
+
this.auth = new Map();
|
|
479
|
+
for (const pid of Object.keys(json.providers)) {
|
|
480
|
+
this.auth.set(pid, inspectApiKey(json.providers[pid]?.apiKey));
|
|
481
|
+
}
|
|
482
|
+
// 重建 provider 列表(保留 selection)
|
|
483
|
+
const newIds = Object.keys(json.providers).sort();
|
|
484
|
+
const stillThere = newIds.includes(this.providers[this.providerIndex]?.id ?? "");
|
|
485
|
+
const built = buildProviders(this.ctx, json);
|
|
486
|
+
this.providers = built.providers;
|
|
487
|
+
if (stillThere) {
|
|
488
|
+
this.providerIndex = this.providers.findIndex(p => p.id === this.providers[this.providerIndex]?.id);
|
|
489
|
+
if (this.providerIndex < 0) this.providerIndex = 0;
|
|
490
|
+
} else {
|
|
491
|
+
this.providerIndex = Math.min(this.providerIndex, Math.max(0, this.providers.length - 1));
|
|
492
|
+
}
|
|
493
|
+
const mlen = this.providers[this.providerIndex]?.models.length ?? 0;
|
|
494
|
+
if (this.modelIndex >= mlen) this.modelIndex = Math.max(0, mlen - 1);
|
|
495
|
+
this.adjustProviderTop();
|
|
496
|
+
this.adjustModelTop();
|
|
497
|
+
this.invalidate();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** 统一处理表单:先关掉当前 dashboard 让 form editor 出来,form 跑完再重开。
|
|
501
|
+
* 关键:ctx.ui.custom() 是 modal dialog,dashboard 的 custom() 会顶住 editor,
|
|
502
|
+
* 所以必须先 onClose(),等 custom() resolve 后才能正常跑 dialog。
|
|
503
|
+
* 现在的 form editor 也是 overlay,所以再次打开时是浮窗式编辑。 */
|
|
504
|
+
private async runForm(
|
|
505
|
+
formFn: (ctx: ExtensionCommandContext, ...args: any[]) => Promise<void>,
|
|
506
|
+
...args: any[]
|
|
507
|
+
): Promise<void> {
|
|
508
|
+
const ctx = this.ctx;
|
|
509
|
+
this.onClose();
|
|
510
|
+
await Promise.resolve();
|
|
511
|
+
// 表单可能早 return(Esc)不调 onDone;用 ensureReopen 标志保证只重开一次
|
|
512
|
+
let reopened = false;
|
|
513
|
+
const ensureReopen = () => {
|
|
514
|
+
if (reopened) return;
|
|
515
|
+
reopened = true;
|
|
516
|
+
void openDashboard(ctx);
|
|
517
|
+
};
|
|
518
|
+
try {
|
|
519
|
+
await (formFn as any)(ctx, ...args, ensureReopen);
|
|
520
|
+
// 写盘后(add/edit/delete 完成)重新读盘刷新 dashboard 数据
|
|
521
|
+
await this.refreshFromDisk();
|
|
522
|
+
} catch (err) {
|
|
523
|
+
ctx.ui.notify(`表单异常: ${err instanceof Error ? err.message : err}`, "error");
|
|
524
|
+
} finally {
|
|
525
|
+
// form 早 return(Esc 中途取消)onDone 不会被调用,dashboard 永远不重开。finally 兜底
|
|
526
|
+
ensureReopen();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private async runSync(sourceProviderId: string): Promise<void> {
|
|
531
|
+
const ctx = this.ctx;
|
|
532
|
+
this.onClose();
|
|
533
|
+
await Promise.resolve();
|
|
534
|
+
let reopened = false;
|
|
535
|
+
const ensureReopen = () => {
|
|
536
|
+
if (reopened) return;
|
|
537
|
+
reopened = true;
|
|
538
|
+
void openDashboard(ctx);
|
|
539
|
+
};
|
|
540
|
+
try {
|
|
541
|
+
await syncFlow(ctx, { sourceProviderId, onDone: ensureReopen });
|
|
542
|
+
await this.refreshFromDisk();
|
|
543
|
+
} catch (err) {
|
|
544
|
+
ctx.ui.notify(`Sync error: ${err instanceof Error ? err.message : err}`, "error");
|
|
545
|
+
} finally {
|
|
546
|
+
ensureReopen();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** test 调测:t 测当前 model,T 测当前 provider 全部 model。
|
|
551
|
+
* 与 edit/sync 统一:关掉 dashboard → TestPanel 浮窗 → 关闭后重开 dashboard。 */
|
|
552
|
+
private async runTest(testAll: boolean): Promise<void> {
|
|
553
|
+
const ctx = this.ctx;
|
|
554
|
+
const provider = this.providers[this.providerIndex];
|
|
555
|
+
if (!provider) {
|
|
556
|
+
ctx.ui.notify("No provider selected", "warning");
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
let modelIds: string[];
|
|
560
|
+
if (testAll) {
|
|
561
|
+
modelIds = provider.models.map((m) => m.id);
|
|
562
|
+
if (modelIds.length === 0) { ctx.ui.notify(`${provider.id} 无 model`, "warning"); return; }
|
|
563
|
+
} else {
|
|
564
|
+
// t: 测当前 pane 的 model(provider pane 测第一个 model;model pane 测当前 model)
|
|
565
|
+
let modelId: string | undefined;
|
|
566
|
+
if (this.pane === "model") modelId = provider.models[this.modelIndex]?.id;
|
|
567
|
+
else modelId = provider.models[0]?.id;
|
|
568
|
+
if (!modelId) { ctx.ui.notify(`${provider.id} 无 model`, "warning"); return; }
|
|
569
|
+
modelIds = [modelId];
|
|
570
|
+
}
|
|
571
|
+
this.onClose();
|
|
572
|
+
await Promise.resolve();
|
|
573
|
+
try {
|
|
574
|
+
await openTestPanel(ctx, { provider: provider.id, modelIds, mode: "full", concurrency: 3 });
|
|
575
|
+
} catch (err) {
|
|
576
|
+
ctx.ui.notify(`Test error: ${err instanceof Error ? err.message : err}`, "error");
|
|
577
|
+
} finally {
|
|
578
|
+
void openDashboard(ctx);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** 重新从磁盘读(用于 form 写盘后刷新) */
|
|
583
|
+
private async refreshFromDisk(): Promise<void> {
|
|
584
|
+
try { await this.invalidateAndReload(); } catch { /* 静默:UI 仍展示旧数据 */ }
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
invalidate(): void {
|
|
588
|
+
this.cachedWidth = -1;
|
|
589
|
+
this.cachedLines = [];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
render(width: number): string[] {
|
|
593
|
+
if (this.cachedWidth === width && this.cachedLines.length > 0) return this.cachedLines;
|
|
594
|
+
const th = this.theme;
|
|
595
|
+
const body: string[] = [];
|
|
596
|
+
|
|
597
|
+
// box 外边框占 4 列(│×2 + 内边距×2),内容按 width-4 布局避免套框超宽
|
|
598
|
+
const cw = Math.max(20, width - 4);
|
|
599
|
+
|
|
600
|
+
// 1. Header (title + stats)
|
|
601
|
+
body.push(this.renderTitleBar(cw, th));
|
|
602
|
+
|
|
603
|
+
if (this.initError) {
|
|
604
|
+
body.push(th.fg("error", ` ⚠ ${this.initError}`));
|
|
605
|
+
body.push(th.fg("dim", " 按 q 退出,修复 models.json 后 /providers 重开"));
|
|
606
|
+
} else if (this.providers.length === 0) {
|
|
607
|
+
body.push(...this.renderEmptyState(cw, th));
|
|
608
|
+
} else {
|
|
609
|
+
// 2. Top region: 左 providers | 右 models(固定列宽 = cw/2 - sep)
|
|
610
|
+
body.push(...this.renderTopRegion(cw, th));
|
|
611
|
+
|
|
612
|
+
// 3. Bottom region: detail panel(固定高度 = detailViewRows)
|
|
613
|
+
body.push(...this.renderDetailRegion(cw, th));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// 4. Footer(hint 拼接 + wrap,限 2 行)
|
|
617
|
+
body.push(th.fg("borderMuted", "─".repeat(cw)));
|
|
618
|
+
if (this.help) {
|
|
619
|
+
body.push(...this.renderHelp(cw, th));
|
|
620
|
+
} else {
|
|
621
|
+
body.push(...this.renderFooter(cw, th));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// 外边框:浮窗加 box,让 tui 里的 overlay 看起来不糊。title 用 " provider-manager "
|
|
625
|
+
const lines = box(th, width, "provider-manager", body);
|
|
626
|
+
this.cachedWidth = width;
|
|
627
|
+
this.cachedLines = lines;
|
|
628
|
+
return lines;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ------------------------------------------------------------------------
|
|
632
|
+
// Title bar
|
|
633
|
+
// ------------------------------------------------------------------------
|
|
634
|
+
|
|
635
|
+
private renderTitleBar(width: number, th: any): string {
|
|
636
|
+
const totalModels = this.providers.reduce((s, p) => s + p.models.length, 0);
|
|
637
|
+
const authed = Array.from(this.auth.values()).filter(a => a?.hasKey).length;
|
|
638
|
+
const stats = this.providers.length === 0
|
|
639
|
+
? "no providers"
|
|
640
|
+
: `${this.providers.length}P · ${totalModels}M${authed > 0 ? ` · ${authed}✓` : ""}`;
|
|
641
|
+
const title = th.fg("accent", th.bold(" provider-manager "));
|
|
642
|
+
const right = th.fg("dim", " " + stats + " ");
|
|
643
|
+
const titleW = 18;
|
|
644
|
+
const rightW = visibleWidthStrippingTheme(right);
|
|
645
|
+
const fill = Math.max(2, width - titleW - rightW);
|
|
646
|
+
return title + th.fg("borderMuted", "─".repeat(fill)) + right;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ------------------------------------------------------------------------
|
|
650
|
+
// Empty state
|
|
651
|
+
// ------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
private renderEmptyState(width: number, th: any): string[] {
|
|
654
|
+
const out: string[] = [];
|
|
655
|
+
const w = Math.max(20, width - 4);
|
|
656
|
+
const top = "┌" + "─".repeat(w - 2) + "┐";
|
|
657
|
+
const bot = "└" + "─".repeat(w - 2) + "┘";
|
|
658
|
+
const box = (s: string) => th.fg("dim", "│") + th.fg("dim", padBox(s, w - 2)) + th.fg("dim", "│");
|
|
659
|
+
out.push("");
|
|
660
|
+
out.push(th.fg("dim", " " + top));
|
|
661
|
+
out.push(" " + box(" (no providers found)"));
|
|
662
|
+
out.push(" " + box(""));
|
|
663
|
+
out.push(" " + box(" Press " + "[accent]n[/accent] to add the first provider."));
|
|
664
|
+
out.push(" " + box(" Or check ~/.pi/agent/models.json."));
|
|
665
|
+
out.push(" " + th.fg("dim", bot));
|
|
666
|
+
return out;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ------------------------------------------------------------------------
|
|
670
|
+
// Top region (providers | models)
|
|
671
|
+
// ------------------------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
private renderTopRegion(width: number, th: any): string[] {
|
|
674
|
+
const sep = th.fg("borderMuted", " │ ");
|
|
675
|
+
const sepW = visibleWidthStrippingTheme(sep);
|
|
676
|
+
const colWidth = Math.max(20, Math.floor((width - sepW) / 2));
|
|
677
|
+
// 左列固定 providerViewRows,右列固定 modelViewRows,取大者作为区域高度
|
|
678
|
+
const leftLines = this.renderProviderColumn(colWidth, th);
|
|
679
|
+
const rightLines = this.renderModelColumn(colWidth, th);
|
|
680
|
+
const targetRows = Math.max(this.providerViewRows, this.modelViewRows);
|
|
681
|
+
const merged: string[] = [];
|
|
682
|
+
for (let r = 0; r < targetRows; r++) {
|
|
683
|
+
const l = (leftLines[r] ?? "").padEnd(colWidth, " ");
|
|
684
|
+
const rr = rightLines[r] ?? "";
|
|
685
|
+
// 主题标签感知的 pad
|
|
686
|
+
merged.push(visiblePad(l, colWidth) + sep + rr);
|
|
687
|
+
}
|
|
688
|
+
// 区域顶部空 1 行
|
|
689
|
+
return ["", ...merged];
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private renderProviderColumn(width: number, th: any): string[] {
|
|
693
|
+
const lines: string[] = [];
|
|
694
|
+
const totalModels = this.providers.reduce((s, p) => s + p.models.length, 0);
|
|
695
|
+
const authed = Array.from(this.auth.values()).filter(a => a?.hasKey).length;
|
|
696
|
+
const stats = ` ${this.providers.length}·${authed}✓ ${totalModels}m `;
|
|
697
|
+
const headActive = this.pane === "provider";
|
|
698
|
+
const headPrefix = headActive ? "▸ " : " ";
|
|
699
|
+
const headBase = "Providers";
|
|
700
|
+
const headPlain = truncateToWidth(headPrefix + headBase + stats, width);
|
|
701
|
+
const head = headActive ? th.fg("accent", th.bold(headPlain)) : th.fg("muted", th.bold(headPlain));
|
|
702
|
+
lines.push(head);
|
|
703
|
+
|
|
704
|
+
const total = this.providers.length;
|
|
705
|
+
// 列总高 = viewRows;listH = viewRows - 2(header + (current/total) 各 1 行)
|
|
706
|
+
const listH = Math.max(1, this.providerViewRows - 2);
|
|
707
|
+
const needPin = total > listH && this.providerIndex >= listH;
|
|
708
|
+
const viewport = Math.max(1, listH - (needPin ? 1 : 0));
|
|
709
|
+
const startIdx = this.providerTop;
|
|
710
|
+
const endIdx = Math.min(total, startIdx + viewport);
|
|
711
|
+
|
|
712
|
+
// pin-first 行:列表>listH 且 cursor 移出可视区时
|
|
713
|
+
if (needPin && total > 0) {
|
|
714
|
+
const first = this.providers[0]!;
|
|
715
|
+
const auth = this.auth.get(first.id);
|
|
716
|
+
const authIcon = auth?.hasKey ? th.fg("success", "✓ ") : th.fg("error", "✗ ");
|
|
717
|
+
const cnt = th.fg("dim", ` ${first.models.length}m`);
|
|
718
|
+
const line = " " + th.bold(first.id) + authIcon + cnt + th.fg("muted", " (top)");
|
|
719
|
+
lines.push(visiblePad(line, width));
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// 列表项
|
|
723
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
724
|
+
const p = this.providers[i]!;
|
|
725
|
+
const sel = i === this.providerIndex;
|
|
726
|
+
const isActivePane = sel && this.pane === "provider";
|
|
727
|
+
const arrow = isActivePane ? th.fg("accent", "▸ ") : " ";
|
|
728
|
+
const nameTh = sel ? th.bold(p.id) : p.id;
|
|
729
|
+
const auth = this.auth.get(p.id);
|
|
730
|
+
let authIcon = " ";
|
|
731
|
+
if (auth) authIcon = auth.hasKey ? th.fg("success", "✓ ") : th.fg("error", "✗ ");
|
|
732
|
+
const cnt = th.fg("dim", ` ${p.models.length}m`);
|
|
733
|
+
const warn = p.models.length === 0 ? th.fg("warning", " ⚠") : "";
|
|
734
|
+
const line = arrow + nameTh + authIcon + cnt + warn;
|
|
735
|
+
lines.push(visiblePad(line, width));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// 补齐空白:行数到 (viewRows - 1) = listH + pin
|
|
739
|
+
const usedRows = lines.length;
|
|
740
|
+
const listUsed = usedRows - 1; // 不含 header
|
|
741
|
+
const listMax = needPin ? listH : Math.max(listH, listUsed);
|
|
742
|
+
for (let i = listUsed; i < listMax; i++) {
|
|
743
|
+
lines.push(" ".repeat(width));
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// 位置指示 (current/total):固定最后 1 行
|
|
747
|
+
if (total > 0) {
|
|
748
|
+
lines.push(th.fg("muted", ` (${this.providerIndex + 1}/${total})`));
|
|
749
|
+
} else {
|
|
750
|
+
lines.push(th.fg("muted", " (0/0)"));
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return lines;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private renderModelColumn(width: number, th: any): string[] {
|
|
757
|
+
const lines: string[] = [];
|
|
758
|
+
const provider = this.providers[this.providerIndex];
|
|
759
|
+
const models = provider?.models ?? [];
|
|
760
|
+
const rCount = models.filter(m => m.reasoning).length;
|
|
761
|
+
const iCount = models.filter(m => m.input.includes("image")).length;
|
|
762
|
+
const stats = models.length > 0 ? ` ${models.length}m · ${rCount}R · ${iCount}I ` : " 0m ";
|
|
763
|
+
const isHeadActive = this.pane === "model" && !!provider;
|
|
764
|
+
const headPrefix = isHeadActive ? "▸ " : " ";
|
|
765
|
+
const headBase = provider ? `Models (${provider.id})` : "Models";
|
|
766
|
+
const headPlain = truncateToWidth(headPrefix + headBase + stats, width);
|
|
767
|
+
const headColored = isHeadActive ? th.fg("accent", th.bold(headPlain)) : th.fg("muted", th.bold(headPlain));
|
|
768
|
+
lines.push(headColored);
|
|
769
|
+
|
|
770
|
+
// listH = viewRows - 2(header + (current/total))
|
|
771
|
+
const listH = Math.max(1, this.modelViewRows - 2);
|
|
772
|
+
|
|
773
|
+
if (models.length === 0) {
|
|
774
|
+
lines.push(th.fg("dim", " (no models)"));
|
|
775
|
+
lines.push(th.fg("dim", " Press y to sync from remote"));
|
|
776
|
+
// 补齐到 listH 行
|
|
777
|
+
for (let i = lines.length - 1; i < listH; i++) lines.push(" ".repeat(width));
|
|
778
|
+
lines.push(th.fg("muted", " (0/0)"));
|
|
779
|
+
return lines;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const total = models.length;
|
|
783
|
+
const needPin = total > listH && this.modelIndex >= listH;
|
|
784
|
+
const viewport = Math.max(1, listH - (needPin ? 1 : 0));
|
|
785
|
+
const startIdx = this.modelTop;
|
|
786
|
+
const endIdx = Math.min(total, startIdx + viewport);
|
|
787
|
+
|
|
788
|
+
if (needPin) {
|
|
789
|
+
const first = models[0]!;
|
|
790
|
+
const arrow = " ";
|
|
791
|
+
const rFlag = first.reasoning ? "R" : "-";
|
|
792
|
+
const iFlag = first.input.includes("image") ? "I" : "-";
|
|
793
|
+
const flagStr = ` [${rFlag}${iFlag}]`;
|
|
794
|
+
const ctx2 = first.contextWindow ? ` ${formatNum(first.contextWindow)}c` : "";
|
|
795
|
+
const max2 = first.maxTokens ? ` ${formatNum(first.maxTokens)}m` : "";
|
|
796
|
+
const raw = arrow + first.id + flagStr + ctx2 + max2 + th.fg("muted", " (top)");
|
|
797
|
+
lines.push(truncateToWidth(raw, width));
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
801
|
+
const m = models[i]!;
|
|
802
|
+
const sel = i === this.modelIndex;
|
|
803
|
+
const isActivePane = sel && this.pane === "model";
|
|
804
|
+
const arrow = isActivePane ? "▸ " : " ";
|
|
805
|
+
const rFlag = m.reasoning ? "R" : "-";
|
|
806
|
+
const iFlag = m.input.includes("image") ? "I" : "-";
|
|
807
|
+
const flagStr = ` [${rFlag}${iFlag}]`;
|
|
808
|
+
const ctx2 = m.contextWindow ? ` ${formatNum(m.contextWindow)}c` : "";
|
|
809
|
+
const max2 = m.maxTokens ? ` ${formatNum(m.maxTokens)}m` : "";
|
|
810
|
+
const raw = arrow + m.id + flagStr + ctx2 + max2;
|
|
811
|
+
const line = truncateToWidth(raw, width);
|
|
812
|
+
lines.push(sel ? th.fg("accent", line) : line);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// 补齐到 listH 行
|
|
816
|
+
const listUsed = lines.length - 1;
|
|
817
|
+
const listMax = needPin ? listH : Math.max(listH, listUsed);
|
|
818
|
+
for (let i = listUsed; i < listMax; i++) lines.push(" ".repeat(width));
|
|
819
|
+
|
|
820
|
+
// 位置指示
|
|
821
|
+
lines.push(th.fg("muted", ` (${this.modelIndex + 1}/${total})`));
|
|
822
|
+
return lines;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ------------------------------------------------------------------------
|
|
826
|
+
// Detail region (固定高度)
|
|
827
|
+
// ------------------------------------------------------------------------
|
|
828
|
+
|
|
829
|
+
private renderDetailRegion(width: number, th: any): string[] {
|
|
830
|
+
const out: string[] = [];
|
|
831
|
+
// 区域分隔:top region 末尾已有 1 空行 + 1 行内容;detail 顶部再补 1 空行 + 1 行 title
|
|
832
|
+
out.push("");
|
|
833
|
+
|
|
834
|
+
let content: string[] = [];
|
|
835
|
+
if (this.pane === "provider") {
|
|
836
|
+
content = this.renderProviderDetail(width, th);
|
|
837
|
+
} else {
|
|
838
|
+
content = this.renderModelDetail(width, th);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// 限高:超出则截断底部 + 加 "⋮ N more"
|
|
842
|
+
if (content.length > this.detailViewRows) {
|
|
843
|
+
content = content.slice(0, this.detailViewRows - 1);
|
|
844
|
+
content.push(th.fg("muted", ` ⋮ ${content.length - this.detailViewRows + 1} more (use ↑↓ for navigation, ? for help)`));
|
|
845
|
+
}
|
|
846
|
+
while (content.length < this.detailViewRows) {
|
|
847
|
+
content.push(" ".repeat(width));
|
|
848
|
+
}
|
|
849
|
+
out.push(...content);
|
|
850
|
+
return out;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
private renderProviderDetail(width: number, th: any): string[] {
|
|
854
|
+
const lines: string[] = [];
|
|
855
|
+
const p = this.providers[this.providerIndex];
|
|
856
|
+
if (!p) return [th.fg("dim", " (no provider selected)")];
|
|
857
|
+
const auth = this.auth.get(p.id);
|
|
858
|
+
const authIcon = auth
|
|
859
|
+
? (auth.hasKey ? th.fg("success", "✓ ") : th.fg("error", "✗ "))
|
|
860
|
+
: th.fg("dim", " ");
|
|
861
|
+
lines.push(th.fg("accent", th.bold(` ${authIcon} Provider: `)) + th.bold(p.id));
|
|
862
|
+
lines.push("");
|
|
863
|
+
lines.push(th.fg("muted", " Identity"));
|
|
864
|
+
lines.push(` displayName: ${p.displayName || th.fg("dim", "(unset)")}`);
|
|
865
|
+
lines.push(` source: models.json (custom)`);
|
|
866
|
+
lines.push(` models: ${p.models.length}`);
|
|
867
|
+
const raw = this.json?.providers?.[p.id] as any;
|
|
868
|
+
if (raw) {
|
|
869
|
+
lines.push("");
|
|
870
|
+
lines.push(th.fg("muted", " Endpoint"));
|
|
871
|
+
lines.push(` baseUrl: ${raw.baseUrl || th.fg("dim", "(unset)")}`);
|
|
872
|
+
lines.push(` api: ${raw.api || th.fg("dim", "(unset)")}`);
|
|
873
|
+
if (raw.proxy) lines.push(` proxy: ${raw.proxy}`);
|
|
874
|
+
lines.push("");
|
|
875
|
+
lines.push(th.fg("muted", " Auth"));
|
|
876
|
+
lines.push(` apiKey: ${maskApiKey(raw.apiKey)}`);
|
|
877
|
+
lines.push(` authHeader: ${raw.authHeader ? "yes" : "no"}`);
|
|
878
|
+
if (auth) {
|
|
879
|
+
const statusColor = auth.hasKey ? th.fg("success", "✓ set") : th.fg("warning", "✗ empty");
|
|
880
|
+
lines.push(` apiKey status: ${statusColor}${auth.source && auth.source !== "empty" ? th.fg("dim", " (" + auth.source + ")") : ""}`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return lines.map((l) => truncateToWidth(l, width));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
private renderModelDetail(width: number, th: any): string[] {
|
|
887
|
+
const lines: string[] = [];
|
|
888
|
+
const p = this.providers[this.providerIndex];
|
|
889
|
+
const m = p?.models[this.modelIndex];
|
|
890
|
+
if (!m) return [th.fg("dim", " (no model selected)")];
|
|
891
|
+
lines.push(th.fg("accent", th.bold(` Model: `)) + `${p.id} / ${m.id}`);
|
|
892
|
+
lines.push("");
|
|
893
|
+
lines.push(th.fg("muted", " Capabilities"));
|
|
894
|
+
lines.push(` reasoning: ${m.reasoning ? th.fg("accent", "yes") : th.fg("dim", "no")}`);
|
|
895
|
+
lines.push(` input: ${m.input.join(", ") || th.fg("dim", "(none)")}`);
|
|
896
|
+
lines.push("");
|
|
897
|
+
lines.push(th.fg("muted", " Limits"));
|
|
898
|
+
lines.push(` context: ${m.contextWindow?.toLocaleString() ?? th.fg("dim", "?")}`);
|
|
899
|
+
lines.push(` max output: ${m.maxTokens?.toLocaleString() ?? th.fg("dim", "?")}`);
|
|
900
|
+
const tlm = m.thinkingLevelMap;
|
|
901
|
+
if (tlm && typeof tlm === "object") {
|
|
902
|
+
const enabled = (Object.entries(tlm) as [string, string | null][])
|
|
903
|
+
.filter(([, v]) => v !== null && v !== undefined)
|
|
904
|
+
.map(([k]) => k);
|
|
905
|
+
if (enabled.length) {
|
|
906
|
+
lines.push("");
|
|
907
|
+
lines.push(` Thinking levels: ${th.fg("text", enabled.join(", "))}`);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const cost = m.cost;
|
|
911
|
+
if (cost) {
|
|
912
|
+
lines.push("");
|
|
913
|
+
lines.push(th.fg("muted", " Cost"));
|
|
914
|
+
lines.push(` input: $${cost.input}/M`);
|
|
915
|
+
lines.push(` output: $${cost.output}/M`);
|
|
916
|
+
if (cost.cacheRead) lines.push(` cache read: $${cost.cacheRead}/M`);
|
|
917
|
+
if (cost.cacheWrite) lines.push(` cache write: $${cost.cacheWrite}/M`);
|
|
918
|
+
}
|
|
919
|
+
const compat = m.compat;
|
|
920
|
+
if (compat && typeof compat === "object") {
|
|
921
|
+
lines.push("");
|
|
922
|
+
lines.push(th.fg("muted", " Compat"));
|
|
923
|
+
if (typeof (compat as any).supportsDeveloperRole === "boolean") {
|
|
924
|
+
const sdr = (compat as any).supportsDeveloperRole;
|
|
925
|
+
lines.push(` supportsDeveloperRole: ${sdr ? th.fg("success", "yes") : th.fg("warning", "no")}`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return lines.map((l) => truncateToWidth(l, width));
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ------------------------------------------------------------------------
|
|
932
|
+
// Footer (wrap & cap at FOOTER_MAX_LINES)
|
|
933
|
+
// ------------------------------------------------------------------------
|
|
934
|
+
|
|
935
|
+
private renderFooter(width: number, th: any): string[] {
|
|
936
|
+
// 空态:只保留 add 动作
|
|
937
|
+
if (this.providers.length === 0) {
|
|
938
|
+
const line = " n add first provider · ? help · q close";
|
|
939
|
+
return [th.fg("dim", truncateToWidth(line, width))];
|
|
940
|
+
}
|
|
941
|
+
const parts: string[] = ["↑↓/jk nav", "←→ pane", "PgUp/PgDn scroll"];
|
|
942
|
+
if (this.pane === "provider") {
|
|
943
|
+
parts.push("n new", "Enter edit", "y sync");
|
|
944
|
+
} else {
|
|
945
|
+
parts.push("n new", "Enter edit", "t test", "T test-all");
|
|
946
|
+
}
|
|
947
|
+
parts.push("d del", "? help", "q close");
|
|
948
|
+
|
|
949
|
+
// 用 " · " 拼接,然后按 width 软 wrap(每段后尝试换行)
|
|
950
|
+
const innerW = Math.max(20, width - 1); // 留 1 个 leading 空格
|
|
951
|
+
const sep = " · ";
|
|
952
|
+
const out: string[] = [];
|
|
953
|
+
let cur = "";
|
|
954
|
+
let curW = 0;
|
|
955
|
+
for (const p of parts) {
|
|
956
|
+
const w = visualWidth(p);
|
|
957
|
+
const need = cur.length === 0 ? w : curW + sep.length + w;
|
|
958
|
+
if (need > innerW && cur.length > 0) {
|
|
959
|
+
out.push(th.fg("dim", " " + cur));
|
|
960
|
+
cur = p;
|
|
961
|
+
curW = w;
|
|
962
|
+
} else {
|
|
963
|
+
cur = cur.length === 0 ? p : cur + sep + p;
|
|
964
|
+
curW = need;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (cur.length > 0) out.push(th.fg("dim", " " + cur));
|
|
968
|
+
|
|
969
|
+
// 截断到 FOOTER_MAX_LINES:超出则末行后加 "+N more" 提示
|
|
970
|
+
if (out.length > FOOTER_MAX_LINES) {
|
|
971
|
+
const kept = out.slice(0, FOOTER_MAX_LINES - 1);
|
|
972
|
+
const remain = out.length - kept.length;
|
|
973
|
+
kept.push(th.fg("dim", ` ⋮ +${remain} more (press ? for full help)`));
|
|
974
|
+
return kept;
|
|
975
|
+
}
|
|
976
|
+
return out;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
private renderHelp(width: number, th: any): string[] {
|
|
980
|
+
const lines: string[] = [
|
|
981
|
+
th.fg("accent", "Key bindings"),
|
|
982
|
+
" ↑/↓ or j/k navigate in current pane",
|
|
983
|
+
" g / G jump to top / bottom",
|
|
984
|
+
" PgUp/PgDn page scroll",
|
|
985
|
+
" ← / → switch between Providers and Models pane",
|
|
986
|
+
" Enter edit selected provider / model",
|
|
987
|
+
" d delete (with confirm)",
|
|
988
|
+
" y sync — fetch remote models for selected provider",
|
|
989
|
+
" ? toggle this help",
|
|
990
|
+
" q / Esc close dashboard",
|
|
991
|
+
];
|
|
992
|
+
if (this.pane === "provider") {
|
|
993
|
+
lines.splice(5, 0, " n new provider (model 仍走 sync)");
|
|
994
|
+
} else {
|
|
995
|
+
lines.splice(5, 0, " n new model manually (sync 拉不到时;走 defaultModel 模板)", " t / T test current model / test all in provider");
|
|
996
|
+
}
|
|
997
|
+
// help 也限 2 行(实际内容比较多,截到 2 行 + "…" 提示,避免无界增长)
|
|
998
|
+
const head = lines[0]!;
|
|
999
|
+
const body = lines.slice(1, FOOTER_MAX_LINES);
|
|
1000
|
+
const more = lines.length - 1 - body.length;
|
|
1001
|
+
return [head, ...body, th.fg("muted", ` ⋮ +${more} more (press any key to dismiss)`)];
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// ============================================================================
|
|
1006
|
+
// 工具
|
|
1007
|
+
// ============================================================================
|
|
1008
|
+
|
|
1009
|
+
function padBox(s: string, width: number): string {
|
|
1010
|
+
// strip theme tags for width measurement
|
|
1011
|
+
const w = visibleWidthStrippingTheme(s);
|
|
1012
|
+
if (w >= width) return s + " ".repeat(Math.max(0, width - w));
|
|
1013
|
+
return s + " ".repeat(width - w);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function formatNum(n: number): string {
|
|
1017
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
|
|
1018
|
+
if (n >= 1_000) return (n / 1_000).toFixed(0) + "k";
|
|
1019
|
+
return String(n);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function inspectApiKey(apiKey: unknown): { hasKey: boolean; source?: string } {
|
|
1023
|
+
if (typeof apiKey !== "string" || apiKey.length === 0) return { hasKey: false, source: "empty" };
|
|
1024
|
+
if (apiKey.startsWith("!")) return { hasKey: true, source: "models.json_command" };
|
|
1025
|
+
if (/^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(apiKey)) return { hasKey: true, source: "models.json_env" };
|
|
1026
|
+
return { hasKey: true, source: "models_json_key" };
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// ============================================================================
|
|
1030
|
+
// TestPanel — t/T 测试结果浮窗(与 dashboard / form / checklist 同为 overlay)
|
|
1031
|
+
// ============================================================================
|
|
1032
|
+
|
|
1033
|
+
/** TestPanel 结果区可视行数(固定高度;多 model 走紧凑 1 行/model,超出滚动) */
|
|
1034
|
+
export const TEST_RESULT_VIEW_ROWS = 12;
|
|
1035
|
+
|
|
1036
|
+
export type TestPanelOpts = {
|
|
1037
|
+
ctx: ExtensionCommandContext;
|
|
1038
|
+
provider: string;
|
|
1039
|
+
modelIds: string[];
|
|
1040
|
+
mode?: TestMode;
|
|
1041
|
+
concurrency?: number;
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
class TestPanel {
|
|
1045
|
+
static __test = true;
|
|
1046
|
+
private ctx: ExtensionCommandContext;
|
|
1047
|
+
private provider: string;
|
|
1048
|
+
private modelIds: string[];
|
|
1049
|
+
private mode: TestMode;
|
|
1050
|
+
private concurrency: number;
|
|
1051
|
+
private results: (TestResult | undefined)[] = [];
|
|
1052
|
+
private doneCount = 0;
|
|
1053
|
+
private finished = false;
|
|
1054
|
+
private closed = false;
|
|
1055
|
+
private top = 0;
|
|
1056
|
+
private tui: { requestRender(): void };
|
|
1057
|
+
private theme: any;
|
|
1058
|
+
private done: () => void;
|
|
1059
|
+
private cachedWidth = -1;
|
|
1060
|
+
private cachedLines: string[] = [];
|
|
1061
|
+
|
|
1062
|
+
constructor(opts: TestPanelOpts & { tui: { requestRender(): void }; theme: any; done: () => void }) {
|
|
1063
|
+
this.ctx = opts.ctx;
|
|
1064
|
+
this.provider = opts.provider;
|
|
1065
|
+
this.modelIds = opts.modelIds;
|
|
1066
|
+
this.mode = opts.mode ?? "full";
|
|
1067
|
+
this.concurrency = opts.concurrency ?? 3;
|
|
1068
|
+
this.tui = opts.tui;
|
|
1069
|
+
this.theme = opts.theme;
|
|
1070
|
+
this.done = opts.done;
|
|
1071
|
+
void this.run();
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** 跑测试并实时刷新;q/Esc 关闭后继续在后台跑完(结果进 session cache),不再 render。 */
|
|
1075
|
+
private async run(): Promise<void> {
|
|
1076
|
+
try {
|
|
1077
|
+
if (this.modelIds.length === 1) {
|
|
1078
|
+
const r = await testModel({ ctx: this.ctx as any, provider: this.provider, model: this.modelIds[0]!, mode: this.mode });
|
|
1079
|
+
if (this.closed) return;
|
|
1080
|
+
this.results[0] = r;
|
|
1081
|
+
this.doneCount = 1;
|
|
1082
|
+
} else {
|
|
1083
|
+
await testProvider({
|
|
1084
|
+
ctx: this.ctx as any,
|
|
1085
|
+
provider: this.provider,
|
|
1086
|
+
modelIds: this.modelIds,
|
|
1087
|
+
mode: this.mode,
|
|
1088
|
+
concurrency: this.concurrency,
|
|
1089
|
+
onProgress: (done, _total, result) => {
|
|
1090
|
+
if (this.closed) return;
|
|
1091
|
+
const idx = this.modelIds.indexOf(result.model);
|
|
1092
|
+
if (idx >= 0) this.results[idx] = result;
|
|
1093
|
+
this.doneCount = done;
|
|
1094
|
+
this.top = Math.max(0, this.resultLines().length - TEST_RESULT_VIEW_ROWS);
|
|
1095
|
+
this.invalidate();
|
|
1096
|
+
this.tui.requestRender();
|
|
1097
|
+
},
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
} catch {
|
|
1101
|
+
// testModel/testProvider 内部已逐个 catch;这里兜底防面板崩
|
|
1102
|
+
}
|
|
1103
|
+
if (this.closed) return;
|
|
1104
|
+
this.finished = true;
|
|
1105
|
+
this.top = Math.max(0, this.resultLines().length - TEST_RESULT_VIEW_ROWS);
|
|
1106
|
+
this.invalidate();
|
|
1107
|
+
this.tui.requestRender();
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
handleInput(data: string): void {
|
|
1111
|
+
if (matchesKey(data, "escape") || data === "q") {
|
|
1112
|
+
this.closed = true;
|
|
1113
|
+
this.done();
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
if (!this.finished) return;
|
|
1117
|
+
const maxTop = Math.max(0, this.resultLines().length - TEST_RESULT_VIEW_ROWS);
|
|
1118
|
+
if ((matchesKey(data, "down") || data === "j") && this.top < maxTop) {
|
|
1119
|
+
this.top++;
|
|
1120
|
+
this.invalidate();
|
|
1121
|
+
} else if ((matchesKey(data, "up") || data === "k") && this.top > 0) {
|
|
1122
|
+
this.top--;
|
|
1123
|
+
this.invalidate();
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
invalidate(): void {
|
|
1128
|
+
this.cachedWidth = -1;
|
|
1129
|
+
this.cachedLines = [];
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** 全部结果行(未截断)。多 model 紧凑:1 行/model + 失败详情;单 model 完整 formatTestResult。 */
|
|
1133
|
+
private resultLines(): string[] {
|
|
1134
|
+
const th = this.theme;
|
|
1135
|
+
const out: string[] = [];
|
|
1136
|
+
const compact = this.modelIds.length > 1;
|
|
1137
|
+
for (let i = 0; i < this.modelIds.length; i++) {
|
|
1138
|
+
const r = this.results[i];
|
|
1139
|
+
if (!r) continue;
|
|
1140
|
+
if (compact) {
|
|
1141
|
+
const icon = r.ok ? th.fg("success", "✓ ") : th.fg("error", "✗ ");
|
|
1142
|
+
const id = r.ok ? r.model : th.bold(r.model);
|
|
1143
|
+
out.push(` ${icon}${id} ${th.fg("dim", `(${r.latencyMs}ms)`)}`);
|
|
1144
|
+
if (!r.ok) {
|
|
1145
|
+
const err = r.checks.auth.error ?? r.checks.reachable.error ?? r.checks.generated?.error;
|
|
1146
|
+
if (err) out.push(` ${th.fg("error", "✗")} ${th.fg("dim", err)}`);
|
|
1147
|
+
}
|
|
1148
|
+
} else {
|
|
1149
|
+
for (const ln of formatTestResult(r).split("\n")) out.push(" " + ln);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return out;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
render(width: number): string[] {
|
|
1156
|
+
if (this.cachedWidth === width && this.cachedLines.length > 0) return this.cachedLines;
|
|
1157
|
+
const th = this.theme;
|
|
1158
|
+
const body: string[] = [];
|
|
1159
|
+
|
|
1160
|
+
// box 外边框占 4 列(│×2 + 内边距×2),内容按 width-4 布局避免套框超宽
|
|
1161
|
+
const cw = Math.max(20, width - 4);
|
|
1162
|
+
if (this.finished) {
|
|
1163
|
+
const okCount = this.results.filter(r => r?.ok).length;
|
|
1164
|
+
const allOk = okCount === this.modelIds.length;
|
|
1165
|
+
body.push(th.fg(allOk ? "success" : "warning", ` ${allOk ? "✓" : "✗"} ${this.modelIds.length} tested, ${okCount} ok`));
|
|
1166
|
+
} else {
|
|
1167
|
+
body.push(th.fg("dim", ` testing ${this.doneCount}/${this.modelIds.length} ...`));
|
|
1168
|
+
}
|
|
1169
|
+
body.push("");
|
|
1170
|
+
const all = this.resultLines();
|
|
1171
|
+
const start = this.top;
|
|
1172
|
+
const end = Math.min(all.length, start + TEST_RESULT_VIEW_ROWS);
|
|
1173
|
+
for (let i = start; i < end; i++) body.push(truncateForRender(all[i]!, cw));
|
|
1174
|
+
if (end < all.length) body.push(th.fg("muted", ` ⋮ ${all.length - end} more below (↓)`));
|
|
1175
|
+
else if (start > 0) body.push(th.fg("muted", ` ⋮ ${start} above (↑)`));
|
|
1176
|
+
body.push("");
|
|
1177
|
+
if (this.finished) {
|
|
1178
|
+
const okCount = this.results.filter(r => r?.ok).length;
|
|
1179
|
+
body.push(` ${this.provider}: ${okCount}/${this.modelIds.length} ok`);
|
|
1180
|
+
}
|
|
1181
|
+
body.push(th.fg("borderMuted", "─".repeat(cw)));
|
|
1182
|
+
body.push(th.fg("dim", this.finished ? " ↑↓/jk scroll · q/Esc close" : " testing… · q/Esc close"));
|
|
1183
|
+
const title = this.modelIds.length === 1
|
|
1184
|
+
? `Test ${this.provider}/${this.modelIds[0]}`
|
|
1185
|
+
: `Test ${this.provider}: ${this.modelIds.length} models`;
|
|
1186
|
+
const lines = box(th, width, title, body);
|
|
1187
|
+
this.cachedWidth = width;
|
|
1188
|
+
this.cachedLines = lines;
|
|
1189
|
+
return lines;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// ============================================================================
|
|
1194
|
+
// 对外 API
|
|
1195
|
+
// ============================================================================
|
|
1196
|
+
|
|
1197
|
+
export { Dashboard, TestPanel };
|
|
1198
|
+
|
|
1199
|
+
/** 打开 Dashboard(TUI 模式);非 TUI 走 fallback
|
|
1200
|
+
* 现在的实现是浮窗:ctx.ui.custom({ overlay: true })。
|
|
1201
|
+
* 子流程(add/edit/sync)也以 overlay 形式打开(见 forms.ts runFormEditor 等),整个会话不会被任何 form 顶掉。 */
|
|
1202
|
+
export async function openDashboard(ctx: ExtensionCommandContext): Promise<void> {
|
|
1203
|
+
if (ctx.mode !== "tui") {
|
|
1204
|
+
ctx.ui.notify("Dashboard requires TUI mode. Try /providers ls in this mode.", "error");
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
await ctx.ui.custom<void>(
|
|
1208
|
+
(_tui, theme, _kb, done) => {
|
|
1209
|
+
const dash = new Dashboard(ctx, theme, () => done());
|
|
1210
|
+
dash.init();
|
|
1211
|
+
return dash;
|
|
1212
|
+
},
|
|
1213
|
+
{
|
|
1214
|
+
overlay: true,
|
|
1215
|
+
overlayOptions: {
|
|
1216
|
+
anchor: "center",
|
|
1217
|
+
width: OVERLAY_WIDTH,
|
|
1218
|
+
maxWidth: OVERLAY_MAX_WIDTH,
|
|
1219
|
+
// minWidth 给窄终端一个下限(terminal 宽度 < width 时 overlay 框架会自适应)
|
|
1220
|
+
minWidth: 60,
|
|
1221
|
+
},
|
|
1222
|
+
},
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/** 打开测试浮窗(TUI 模式);非 TUI 走 notify fallback(跑完一次性展示) */
|
|
1227
|
+
export async function openTestPanel(ctx: ExtensionCommandContext, opts: TestPanelOpts): Promise<void> {
|
|
1228
|
+
if (ctx.mode !== "tui") {
|
|
1229
|
+
const mode = opts.mode ?? "full";
|
|
1230
|
+
if (opts.modelIds.length === 1) {
|
|
1231
|
+
const r = await testModel({ ctx: ctx as any, provider: opts.provider, model: opts.modelIds[0]!, mode });
|
|
1232
|
+
ctx.ui.notify(formatTestResult(r), "info");
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
const results = await testProvider({ ctx: ctx as any, provider: opts.provider, modelIds: opts.modelIds, mode, concurrency: opts.concurrency ?? 3 });
|
|
1236
|
+
const okCount = results.filter(r => r.ok).length;
|
|
1237
|
+
const summary = results.map(r => formatTestResult(r)).join("\n\n") + `\n${opts.provider}: ${okCount}/${results.length} ok`;
|
|
1238
|
+
ctx.ui.notify(summary, "info");
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
await ctx.ui.custom<void>(
|
|
1242
|
+
(tui, theme, _kb, done) => new TestPanel({ ctx, provider: opts.provider, modelIds: opts.modelIds, mode: opts.mode, concurrency: opts.concurrency, tui, theme, done: () => done() }),
|
|
1243
|
+
{ overlay: true, overlayOptions: { anchor: "center", width: 88, minWidth: 60 } },
|
|
1244
|
+
);
|
|
1245
|
+
}
|