@fanchaozz/provider-manager 0.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/ui.ts ADDED
@@ -0,0 +1,643 @@
1
+ /**
2
+ * ui.ts — TUI Dashboard
3
+ *
4
+ * 双面板(Providers | Models)+ 详情面板 + 底部键位提示。
5
+ * 渲染层纯字符串,零外部 TUI 依赖。
6
+ *
7
+ * 1.2+ 阶段:CRUD + sync + 详情面板;内置 provider 不覆盖(走 pi 的 /model)。
8
+ */
9
+
10
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
+ import { readFileSync, existsSync } from "node:fs";
12
+ import { readModelsJson, getModelsJsonPath, maskApiKey, type ModelsJson, type ProviderConfig, type ModelConfig } from "./store.ts";
13
+ import {
14
+ addProviderFlow,
15
+ editProviderFlow,
16
+ deleteProviderFlow,
17
+ addModelFlow,
18
+ editModelFlow,
19
+ deleteModelFlow,
20
+ syncFlow,
21
+ } from "./forms.ts";
22
+ import { testModel, testProvider, formatTestResult, getCached, type TestResult } from "./test.ts";
23
+
24
+ // ============================================================================
25
+ // 类型
26
+ // ============================================================================
27
+
28
+ type ModelRow = {
29
+ id: string;
30
+ provider: string;
31
+ contextWindow?: number;
32
+ maxTokens?: number;
33
+ reasoning: boolean;
34
+ input: string[];
35
+ hasApiKey: boolean;
36
+ };
37
+
38
+ type ProviderRow = {
39
+ id: string;
40
+ displayName: string;
41
+ models: ModelRow[];
42
+ };
43
+
44
+ // ============================================================================
45
+ // 工具
46
+ // ============================================================================
47
+
48
+ /** 匹配 pi 风格的 key 字符串:escape / ctrl+c / up / down / enter / tab 等 */
49
+ function matchesKey(data: string, key: string): boolean {
50
+ const k = key.toLowerCase();
51
+ // ctrl+X
52
+ if (k.startsWith("ctrl+")) {
53
+ const ch = k.slice(5);
54
+ return data === `\x1b${ch}` || (ch.length === 1 && data === ch && data.charCodeAt(0) < 32);
55
+ }
56
+ switch (k) {
57
+ case "escape":
58
+ return data === "\x1b" || data === "\x1b\x1b";
59
+ case "enter":
60
+ case "return":
61
+ return data === "\r" || data === "\n";
62
+ case "tab":
63
+ return data === "\t";
64
+ case "backspace":
65
+ return data === "\x7f" || data === "\b";
66
+ case "up":
67
+ return data === "\x1b[A" || data === "\x1bOA";
68
+ case "down":
69
+ return data === "\x1b[B" || data === "\x1bOB";
70
+ case "left":
71
+ return data === "\x1b[D" || data === "\x1bOD";
72
+ case "right":
73
+ return data === "\x1b[C" || data === "\x1bOC";
74
+ case "home":
75
+ return data === "\x1b[H" || data === "\x1bOH";
76
+ case "end":
77
+ return data === "\x1b[F" || data === "\x1bOF";
78
+ case "pageup":
79
+ return data === "\x1b[5~";
80
+ case "pagedown":
81
+ return data === "\x1b[6~";
82
+ }
83
+ // 单字符
84
+ if (k.length === 1) return data === k;
85
+ return false;
86
+ }
87
+
88
+ /** 按视觉宽度截断(中文算 2) */
89
+ function truncateToWidth(s: string, max: number, ellipsis = "…"): string {
90
+ if (max <= 0) return "";
91
+ let w = 0;
92
+ let out = "";
93
+ for (const ch of s) {
94
+ const cw = isWide(ch) ? 2 : 1;
95
+ if (w + cw > max) return out + (ellipsis && w + 1 <= max ? ellipsis : "");
96
+ out += ch;
97
+ w += cw;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function isWide(ch: string): boolean {
103
+ const code = ch.codePointAt(0) ?? 0;
104
+ return code > 0x1100 && (
105
+ (code >= 0x1100 && code <= 0x115f) ||
106
+ (code >= 0x2e80 && code <= 0x9fff) ||
107
+ (code >= 0xac00 && code <= 0xd7a3) ||
108
+ (code >= 0xff00 && code <= 0xff60) ||
109
+ (code >= 0xffe0 && code <= 0xffe6)
110
+ );
111
+ }
112
+
113
+ function pad(s: string, width: number): string {
114
+ let w = visualWidth(s);
115
+ if (w >= width) return s;
116
+ return s + " ".repeat(width - w);
117
+ }
118
+
119
+ /** 主题感知的 pad:把 [tag]...[/tag] 标记当作零宽,padding 补到目标可见宽度 */
120
+ function visiblePad(s: string, width: number): string {
121
+ const w = visibleWidthStrippingTheme(s);
122
+ if (w >= width) return s;
123
+ return s + " ".repeat(width - w);
124
+ }
125
+
126
+ /** 跳过 ANSI 转义序列和已知主题标签后算视觉宽度
127
+ * - ANSI: \x1b[...m(零宽颜色码)
128
+ * - 旧格式: [tag]...[/tag](只跳过白名单内的;其他 [..] 按字面文本计)
129
+ */
130
+ function visibleWidthStrippingTheme(s: string): number {
131
+ let w = 0;
132
+ let i = 0;
133
+ while (i < s.length) {
134
+ // ANSI 转义序列:\x1b[ ... m 或 \x1b[ ... <字母>
135
+ if (s[i] === "\x1b" && i + 1 < s.length && s[i + 1] === "[") {
136
+ const close = s.indexOf("m", i + 2);
137
+ if (close !== -1) { i = close + 1; continue; }
138
+ // 其他 CSI 序列:结尾是某个字母
139
+ const csiEnd = s.slice(i + 2).search(/[A-Za-z]/);
140
+ if (csiEnd !== -1) { i = i + 2 + csiEnd + 1; continue; }
141
+ }
142
+ // 旧主题标签 [tag]
143
+ if (s[i] === "[") {
144
+ const close = s.indexOf("]", i + 1);
145
+ if (close !== -1) {
146
+ const inner = s.slice(i + 1, close);
147
+ if (KNOWN_THEME_TAGS.has(inner) || (inner.startsWith("/") && KNOWN_THEME_TAGS.has(inner.slice(1)))) {
148
+ i = close + 1;
149
+ continue;
150
+ }
151
+ }
152
+ }
153
+ w += isWide(s[i]!) ? 2 : 1;
154
+ i++;
155
+ }
156
+ return w;
157
+ }
158
+
159
+ /** 已知主题标签名集合。渲染器在 [name] 找不到主题色时会把整个 [name] 当字面文本输出 */
160
+ const KNOWN_THEME_TAGS = new Set<string>([
161
+ "accent", "warning", "dim", "success", "error", "muted", "text",
162
+ "borderMuted", "border", "borderAccent",
163
+ "background", "primary", "secondary",
164
+ "toolTitle", "toolOutput", "toolBg",
165
+ "customMessageBg", "userMessageBg", "thinking",
166
+ "bold", "italic", "underline", "inverse",
167
+ "selection", "comment", "keyword", "string", "number", "function",
168
+ "variable", "type", "operator", "punctuation", "property",
169
+ ]);
170
+
171
+ function visualWidth(s: string): number {
172
+ let w = 0;
173
+ for (const ch of s) w += isWide(ch) ? 2 : 1;
174
+ return w;
175
+ }
176
+
177
+ // ============================================================================
178
+ // 数据加载
179
+ // ============================================================================
180
+
181
+ function buildProviders(ctx: ExtensionCommandContext, json: ModelsJson): { providers: ProviderRow[]; auth: Map<string, { hasKey: boolean; source?: string }> } {
182
+ // 只看 models.json 里的自定义 provider;内置 provider 走 pi 的 /model,不在插件覆盖范围
183
+ const customIds = Object.keys(json.providers).sort();
184
+
185
+ const auth = new Map<string, { hasKey: boolean; source?: string }>();
186
+ const authStatus = (ctx.modelRegistry as any).getProviderAuthStatus;
187
+ if (typeof authStatus === "function") {
188
+ for (const pid of customIds) {
189
+ try {
190
+ const s = authStatus(pid);
191
+ auth.set(pid, { hasKey: !!s?.ok, source: s?.source });
192
+ } catch {
193
+ auth.set(pid, { hasKey: false });
194
+ }
195
+ }
196
+ }
197
+
198
+ const providers: ProviderRow[] = customIds.map((pid) => {
199
+ const customModels = (json.providers[pid]?.models ?? []) as ModelConfig[];
200
+ return {
201
+ id: pid,
202
+ displayName: ctx.modelRegistry.getProviderDisplayName(pid) ?? pid,
203
+ models: customModels.map((m): ModelRow => ({
204
+ id: m.id,
205
+ provider: pid,
206
+ contextWindow: m.contextWindow,
207
+ maxTokens: m.maxTokens,
208
+ reasoning: !!m.reasoning,
209
+ input: m.input ?? ["text"],
210
+ hasApiKey: auth.get(pid)?.hasKey ?? false,
211
+ })),
212
+ };
213
+ });
214
+ return { providers, auth };
215
+ }
216
+
217
+ // ============================================================================
218
+ // Dashboard 组件
219
+ // ============================================================================
220
+
221
+ type Pane = "provider" | "model";
222
+
223
+ class Dashboard {
224
+ // exposed for testing
225
+ static __test = true;
226
+ private providers: ProviderRow[] = [];
227
+ private auth = new Map<string, { hasKey: boolean; source?: string }>();
228
+ private providerIndex = 0;
229
+ private modelIndex = 0;
230
+ private pane: Pane = "provider";
231
+ private help = false;
232
+ private initError?: string;
233
+ private cachedWidth = -1;
234
+ private cachedLines: string[] = [];
235
+ private onClose: () => void;
236
+ private theme: any;
237
+ private ctx: ExtensionCommandContext;
238
+ private json: ModelsJson = { providers: {} };
239
+
240
+ constructor(
241
+ ctx: ExtensionCommandContext,
242
+ theme: any,
243
+ onClose: () => void,
244
+ ) {
245
+ this.ctx = ctx;
246
+ this.theme = theme;
247
+ this.onClose = onClose;
248
+ }
249
+
250
+ /** 同步初始化:custom() 返回前必须先有数据,避免首帧 "(no providers found)" 闪烁 */
251
+ init(): void {
252
+ const path = getModelsJsonPath();
253
+ let json: ModelsJson = { providers: {} };
254
+ if (existsSync(path)) {
255
+ try {
256
+ const text = readFileSync(path, "utf8");
257
+ const parsed = JSON.parse(text);
258
+ if (parsed && typeof parsed === "object" && parsed.providers && typeof parsed.providers === "object") {
259
+ json = parsed as ModelsJson;
260
+ }
261
+ } catch (err) {
262
+ this.initError = `models.json 解析失败: ${err instanceof Error ? err.message : err}`;
263
+ json = { providers: {} };
264
+ }
265
+ }
266
+ this.json = json;
267
+ this.initError = undefined;
268
+ const built = buildProviders(this.ctx, json);
269
+ this.providers = built.providers;
270
+ this.auth = built.auth;
271
+ if (this.providerIndex >= this.providers.length) this.providerIndex = Math.max(0, this.providers.length - 1);
272
+ const curModels = this.providers[this.providerIndex]?.models ?? [];
273
+ if (this.modelIndex >= curModels.length) this.modelIndex = Math.max(0, curModels.length - 1);
274
+ }
275
+
276
+ handleInput(data: string): void {
277
+ if (matchesKey(data, "escape") || data === "q") {
278
+ this.onClose();
279
+ return;
280
+ }
281
+ if (matchesKey(data, "tab")) {
282
+ this.pane = this.pane === "provider" ? "model" : "provider";
283
+ this.invalidate();
284
+ return;
285
+ }
286
+ if (data === "?") {
287
+ this.help = !this.help;
288
+ this.invalidate();
289
+ return;
290
+ }
291
+ // 导航(循环:第 1 个按 ↑ 跳最后,最后按 ↓ 跳第 1 个)
292
+ const items = this.pane === "provider" ? this.providers : (this.providers[this.providerIndex]?.models ?? []);
293
+ if (items.length === 0) {
294
+ // 空列表:什么都不做
295
+ } else if (matchesKey(data, "up") || data === "k") {
296
+ this.setIndex(this.index() === 0 ? items.length - 1 : this.index() - 1);
297
+ } else if (matchesKey(data, "down") || data === "j") {
298
+ this.setIndex((this.index() + 1) % items.length);
299
+ } else if (data === "g") {
300
+ this.setIndex(0);
301
+ } else if (data === "G") {
302
+ this.setIndex(items.length - 1);
303
+ } else if (data === "n") {
304
+ // n: 新增。若没 provider 则强制切到 provider pane 走 addProviderFlow。
305
+ if (this.pane === "provider") {
306
+ void this.runForm(addProviderFlow);
307
+ } else {
308
+ const cur = this.providers[this.providerIndex];
309
+ if (cur) {
310
+ void this.runForm(addModelFlow, cur.id);
311
+ } else {
312
+ // 无 provider:切到 provider pane 再走 add
313
+ this.pane = "provider";
314
+ this.invalidate();
315
+ this.ctx.ui.notify("先新建 provider:按 n 添加", "info");
316
+ }
317
+ }
318
+ } else if (data === "e") {
319
+ if (this.pane === "provider" && this.providers[this.providerIndex]) {
320
+ const id = this.providers[this.providerIndex].id;
321
+ void this.runForm(editProviderFlow, id);
322
+ } else {
323
+ const prov = this.providers[this.providerIndex];
324
+ const m = prov?.models[this.modelIndex];
325
+ if (prov && m) void this.runForm(editModelFlow, prov.id, m.id);
326
+ }
327
+ } else if (data === "d") {
328
+ const prov = this.providers[this.providerIndex];
329
+ if (this.pane === "provider" && prov) {
330
+ const id = prov.id;
331
+ void this.runForm(deleteProviderFlow, id);
332
+ } else if (prov && prov.models[this.modelIndex]) {
333
+ const id = prov.models[this.modelIndex].id;
334
+ const pid = prov.id;
335
+ void this.runForm(deleteModelFlow, pid, id);
336
+ }
337
+ } else if (data === "y") {
338
+ const sel = this.providers[this.providerIndex];
339
+ if (sel) void this.runSync(sel.id);
340
+ else this.ctx.ui.notify("No provider selected", "warning");
341
+ } else if (data === "t" || data === "T") {
342
+ void this.runTest(data === "T");
343
+ }
344
+ }
345
+
346
+ private index(): number {
347
+ return this.pane === "provider" ? this.providerIndex : this.modelIndex;
348
+ }
349
+ private setIndex(i: number): void {
350
+ if (this.pane === "provider") this.providerIndex = i;
351
+ else this.modelIndex = i;
352
+ this.invalidate();
353
+ }
354
+
355
+ private async invalidateAndReload(): Promise<void> {
356
+ // 重新读 models.json 并刷新 auth 缓存(仅自定义 provider)
357
+ const json = await readModelsJson();
358
+ this.json = json;
359
+ this.auth = new Map();
360
+ const authStatus = this.ctx.modelRegistry.getProviderAuthStatus;
361
+ if (typeof authStatus === "function") {
362
+ for (const pid of Object.keys(json.providers)) {
363
+ try {
364
+ const s = (authStatus as any)(pid);
365
+ this.auth.set(pid, { hasKey: !!s?.ok, source: s?.source });
366
+ } catch {
367
+ this.auth.set(pid, { hasKey: false });
368
+ }
369
+ }
370
+ }
371
+ this.invalidate();
372
+ }
373
+
374
+ /** 统一处理表单:先关掉当前 dashboard 让 editor 出来,form 跑完再重开。
375
+ * 关键:ctx.ui.input 是 modal dialog,dashboard 的 custom() 会顶住 editor,
376
+ * 所以必须先 onClose(),等 custom() resolve 后才能正常跑 dialog。
377
+ * form 完成后回调 onDone 重开 dashboard,否则 editor 暴露但用户预期在看 dashboard。
378
+ * formFn 的签名是 (ctx, ...formArgs, onDone?);onDone 可选(缺了不崩,只 notify 不重开)。 */
379
+ private async runForm(
380
+ formFn: (ctx: ExtensionCommandContext, ...args: any[]) => Promise<void>,
381
+ ...args: any[]
382
+ ): Promise<void> {
383
+ const ctx = this.ctx;
384
+ this.onClose(); // 立刻关掉当前 custom()
385
+ await Promise.resolve(); // 等 custom() resolve
386
+ try {
387
+ await (formFn as any)(ctx, ...args, () => {
388
+ void openDashboard(ctx);
389
+ });
390
+ } catch (err) {
391
+ // 任何异常都不让 pi crash
392
+ ctx.ui.notify(`表单异常: ${err instanceof Error ? err.message : err}`, "error");
393
+ void openDashboard(ctx);
394
+ } finally {
395
+ // form 早 return(Esc 中途取消)时 onDone 不会被调用,dashboard 永远不重开。
396
+ // 用 finally 兜底,确保 dashboard 总是恢复。
397
+ void openDashboard(ctx);
398
+ }
399
+ }
400
+
401
+ /** sync 的专用包装:runForm 是 `(ctx, ...args, onDone)` 风格,syncFlow 是 `(ctx, opts)` 风格 */
402
+ private async runSync(sourceProviderId: string): Promise<void> {
403
+ const ctx = this.ctx;
404
+ this.onClose();
405
+ await Promise.resolve();
406
+ try {
407
+ await syncFlow(ctx, { sourceProviderId, onDone: () => { void openDashboard(ctx); } });
408
+ } catch (err) {
409
+ ctx.ui.notify(`Sync error: ${err instanceof Error ? err.message : err}`, "error");
410
+ } finally {
411
+ void openDashboard(ctx);
412
+ }
413
+ }
414
+
415
+ /** test 调测:t 测当前 model,T 测当前 provider 全部 model。保持 dashboard 不关,结束后重开。 */
416
+ private async runTest(testAll: boolean): Promise<void> {
417
+ const ctx = this.ctx as any;
418
+ const provider = this.providers[this.providerIndex];
419
+ if (!provider) {
420
+ ctx.ui.notify("No provider selected", "warning");
421
+ return;
422
+ }
423
+ if (testAll) {
424
+ const modelIds = provider.models.map((m) => m.id);
425
+ if (modelIds.length === 0) { ctx.ui.notify(`${provider.id} 无 model`, "warning"); return; }
426
+ ctx.ui.notify(`testing ${modelIds.length} model(s) of ${provider.id}...`, "info");
427
+ const results = await testProvider({ ctx, provider: provider.id, modelIds, mode: "full", concurrency: 3 });
428
+ let okCount = 0;
429
+ for (const r of results) {
430
+ if (r.ok) okCount++;
431
+ ctx.ui.notify(formatTestResult(r), r.ok ? "info" : "warning");
432
+ }
433
+ ctx.ui.notify(`${provider.id}: ${okCount}/${results.length} ok`, okCount === results.length ? "success" : "warning");
434
+ } else {
435
+ // t: 测当前 pane 的 model(provider pane 测第一个 model;model pane 测当前 model)
436
+ let modelId: string | undefined;
437
+ if (this.pane === "model") {
438
+ modelId = provider.models[this.modelIndex]?.id;
439
+ } else {
440
+ modelId = provider.models[0]?.id;
441
+ }
442
+ if (!modelId) { ctx.ui.notify(`${provider.id} 无 model`, "warning"); return; }
443
+ ctx.ui.notify(`testing ${provider.id}/${modelId}...`, "info");
444
+ const r = await testModel({ ctx, provider: provider.id, model: modelId, mode: "full" });
445
+ ctx.ui.notify(formatTestResult(r), r.ok ? "success" : "warning");
446
+ }
447
+ this.invalidate();
448
+ }
449
+
450
+ invalidate(): void {
451
+ this.cachedWidth = -1;
452
+ this.cachedLines = [];
453
+ }
454
+
455
+ render(width: number): string[] {
456
+ if (this.cachedWidth === width && this.cachedLines.length > 0) return this.cachedLines;
457
+ const th = this.theme;
458
+ const lines: string[] = [];
459
+
460
+ // 1. Header
461
+ lines.push(th.fg("accent", th.bold(" provider-manager ")) + th.fg("borderMuted", "─".repeat(Math.max(0, width - 20))));
462
+ lines.push("");
463
+
464
+ if (this.initError) {
465
+ lines.push(th.fg("error", ` ⚠ ${this.initError}`));
466
+ lines.push(th.fg("dim", " 按 q 退出,修复 models.json 后 /providers 重开"));
467
+ } else if (this.providers.length === 0) {
468
+ lines.push(th.fg("dim", " (no providers found)"));
469
+ lines.push(th.fg("dim", " 按 n 新建 provider,或检查 ~/.pi/agent/models.json"));
470
+ } else {
471
+ // 2. Body: 两栏
472
+ const colWidth = Math.max(20, Math.floor((width - 3) / 2));
473
+ const leftLines = this.renderProviderColumn(colWidth, th);
474
+ const rightLines = this.renderModelColumn(colWidth, th);
475
+ const rows = Math.max(leftLines.length, rightLines.length);
476
+ const sep = th.fg("borderMuted", " │ ");
477
+ for (let r = 0; r < rows; r++) {
478
+ const l = leftLines[r] ?? "";
479
+ const rr = rightLines[r] ?? "";
480
+ // 关键:visiblePad 只看可见宽度(剥掉 [tag]...[/tag]),不再被主题标签吃掉 padding
481
+ lines.push(visiblePad(l, colWidth) + sep + rr);
482
+ }
483
+ lines.push("");
484
+
485
+ // 3. Detail
486
+ lines.push(th.fg("borderMuted", "─".repeat(width)));
487
+ lines.push(...this.renderDetail(width, th));
488
+ }
489
+
490
+ // 4. Footer
491
+ lines.push(th.fg("borderMuted", "─".repeat(width)));
492
+ if (this.help) {
493
+ lines.push(...this.renderHelp(width, th));
494
+ } else {
495
+ lines.push(th.fg("dim", " ↑↓/jk nav · Tab pane · n new · e edit · d del · y sync · t test · T test-all · ? help · q close"));
496
+ }
497
+ this.cachedWidth = width;
498
+ this.cachedLines = lines;
499
+ return lines;
500
+ }
501
+
502
+ private renderProviderColumn(width: number, th: any): string[] {
503
+ const lines: string[] = [];
504
+ const headerTag = this.pane === "provider" ? th.fg("accent", th.bold("▸ Providers")) : th.fg("muted", " Providers");
505
+ lines.push(truncateToWidth(headerTag, width));
506
+ lines.push("");
507
+ this.providers.forEach((p, i) => {
508
+ const sel = i === this.providerIndex;
509
+ const arrow = sel && this.pane === "provider" ? th.fg("accent", "▸ ") : " ";
510
+ const nameTh = sel ? th.bold(p.id) : p.id;
511
+ const cntThemed = th.fg("dim", ` (${p.models.length})`);
512
+ lines.push(visiblePad(arrow + nameTh + cntThemed, width));
513
+ });
514
+ return lines;
515
+ }
516
+
517
+ private renderModelColumn(width: number, th: any): string[] {
518
+ const lines: string[] = [];
519
+ const provider = this.providers[this.providerIndex];
520
+ const models = provider?.models ?? [];
521
+ const headerTag = this.pane === "model" ? th.fg("accent", th.bold(`▸ Models (${provider?.id ?? "?"})`)) : th.fg("muted", ` Models (${provider?.id ?? "?"})`);
522
+ lines.push(truncateToWidth(headerTag, width));
523
+ lines.push("");
524
+ if (models.length === 0) {
525
+ lines.push(th.fg("dim", " (no models)"));
526
+ }
527
+ models.forEach((m, i) => {
528
+ const sel = i === this.modelIndex;
529
+ const arrow = sel ? (this.pane === "model" ? "▸ " : " ") : " ";
530
+ const flags = [m.reasoning && "R", m.input.includes("image") && "I"].filter(Boolean).join("");
531
+ const ctx2 = m.contextWindow ? ` ${formatNum(m.contextWindow)}c` : "";
532
+ const max2 = m.maxTokens ? ` ${formatNum(m.maxTokens)}m` : "";
533
+ const flagStr = flags ? ` [${flags}]` : "";
534
+ const raw = arrow + m.id + flagStr + ctx2 + max2;
535
+ const line = truncateToWidth(raw, width);
536
+ lines.push(sel ? th.fg("accent", line) : line);
537
+ });
538
+ return lines;
539
+ }
540
+
541
+ private renderDetail(width: number, th: any): string[] {
542
+ const lines: string[] = [];
543
+ if (this.pane === "provider") {
544
+ const p = this.providers[this.providerIndex];
545
+ if (!p) return [th.fg("dim", " (no provider selected)")];
546
+ lines.push(th.fg("accent", th.bold("Provider: ")) + p.id);
547
+ lines.push(` displayName: ${p.displayName}`);
548
+ lines.push(` source: models.json (custom)`);
549
+ lines.push(` models: ${p.models.length}`);
550
+ const auth = this.auth.get(p.id);
551
+ if (auth) lines.push(` auth: ${auth.hasKey ? th.fg("success", "✓ ") + (auth.source ?? "ok") : th.fg("error", "✗ no key")}`);
552
+ // 原始 json(来自 models.json 的话)
553
+ const raw = this.json?.providers?.[p.id];
554
+ if (raw) {
555
+ lines.push(th.fg("muted", " raw (from models.json):"));
556
+ const summary = summarizeProviderRaw(raw);
557
+ lines.push(...summary.map((l) => th.fg("dim", " " + l)));
558
+ }
559
+ } else {
560
+ const p = this.providers[this.providerIndex];
561
+ const m = p?.models[this.modelIndex];
562
+ if (!m) return [th.fg("dim", " (no model selected)")];
563
+ lines.push(th.fg("accent", th.bold("Model: ")) + `${p.id} / ${m.id}`);
564
+ lines.push(` reasoning: ${m.reasoning ? "yes" : "no"}`);
565
+ lines.push(` input: ${m.input.join(", ") || "(none)"}`);
566
+ lines.push(` context: ${m.contextWindow?.toLocaleString() ?? "?"}`);
567
+ lines.push(` max output: ${m.maxTokens?.toLocaleString() ?? "?"}`);
568
+ // raw
569
+ const rawModel = this.json?.providers?.[p.id]?.models?.find((mm: any) => mm.id === m.id);
570
+ if (rawModel) {
571
+ lines.push(th.fg("muted", " raw (from models.json):"));
572
+ lines.push(...summarizeModelRaw(rawModel).map((l) => th.fg("dim", " " + l)));
573
+ }
574
+ }
575
+ return lines.map((l) => truncateToWidth(l, width));
576
+ }
577
+
578
+ private renderHelp(width: number, th: any): string[] {
579
+ return [
580
+ th.fg("accent", "Key bindings"),
581
+ " ↑/↓ or j/k navigate in current pane",
582
+ " g / G jump to top / bottom",
583
+ " Tab switch between Providers and Models pane",
584
+ " n new provider (or model on model pane)",
585
+ " e edit selected provider / model",
586
+ " d delete (with confirm)",
587
+ " y sync — fetch remote models for selected provider",
588
+ " ? toggle this help",
589
+ " q / Esc close dashboard",
590
+ "",
591
+ th.fg("dim", " y sync · t test current model · T test all in provider"),
592
+ ].map((l) => truncateToWidth(l, width));
593
+ }
594
+ }
595
+
596
+ function formatNum(n: number): string {
597
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
598
+ if (n >= 1_000) return (n / 1_000).toFixed(0) + "k";
599
+ return String(n);
600
+ }
601
+
602
+ function summarizeProviderRaw(raw: any): string[] {
603
+ const lines: string[] = [];
604
+ if (raw.baseUrl) lines.push(`baseUrl: ${raw.baseUrl}`);
605
+ if (raw.api) lines.push(`api: ${raw.api}`);
606
+ if (raw.apiKey) lines.push(`apiKey: ${maskApiKey(raw.apiKey)}`);
607
+ if (raw.authHeader) lines.push(`authHeader: ${raw.authHeader}`);
608
+ if (raw.headers && Object.keys(raw.headers).length) lines.push(`headers: ${Object.keys(raw.headers).length} entries`);
609
+ return lines;
610
+ }
611
+
612
+ function summarizeModelRaw(raw: any): string[] {
613
+ const lines: string[] = [];
614
+ if (raw.name) lines.push(`name: ${raw.name}`);
615
+ if (raw.baseUrl) lines.push(`baseUrl: ${raw.baseUrl}`);
616
+ if (raw.api) lines.push(`api: ${raw.api}`);
617
+ if (raw.cost) lines.push(`cost: in ${raw.cost.input} / out ${raw.cost.output}`);
618
+ if (raw.thinkingLevelMap) {
619
+ const keys = Object.keys(raw.thinkingLevelMap);
620
+ lines.push(`thinkingLevelMap: ${keys.length} levels`);
621
+ }
622
+ return lines;
623
+ }
624
+
625
+ // ============================================================================
626
+ // 对外 API
627
+ // ============================================================================
628
+
629
+ export { Dashboard }; // for unit tests
630
+
631
+
632
+ /** 打开 Dashboard(TUI 模式);非 TUI 走 fallback */
633
+ export async function openDashboard(ctx: ExtensionCommandContext): Promise<void> {
634
+ if (ctx.mode !== "tui") {
635
+ ctx.ui.notify("Dashboard requires TUI mode. Try /providers ls in this mode.", "error");
636
+ return;
637
+ }
638
+ await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
639
+ const dash = new Dashboard(ctx, theme, () => done());
640
+ dash.init(); // 同步初始化,首帧就有数据
641
+ return dash;
642
+ });
643
+ }