@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.
Files changed (7) hide show
  1. package/README.md +267 -257
  2. package/README_EN.md +249 -249
  3. package/commands.ts +3 -17
  4. package/components.ts +1193 -866
  5. package/forms.ts +19 -1
  6. package/package.json +1 -1
  7. package/ui.ts +1245 -761
package/ui.ts CHANGED
@@ -1,761 +1,1245 @@
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
- addModelFlow,
16
- editProviderFlow,
17
- deleteProviderFlow,
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
- // 详情面板需要从 raw ModelConfig 透传
37
- thinkingLevelMap?: Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max", string | null>>;
38
- cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
39
- compat?: Record<string, unknown>;
40
- };
41
-
42
- type ProviderRow = {
43
- id: string;
44
- displayName: string;
45
- models: ModelRow[];
46
- };
47
-
48
- // ============================================================================
49
- // 工具
50
- // ============================================================================
51
-
52
- /** 匹配 pi 风格的 key 字符串:escape / ctrl+c / up / down / enter / tab 等 */
53
- function matchesKey(data: string, key: string): boolean {
54
- const k = key.toLowerCase();
55
- // ctrl+X
56
- if (k.startsWith("ctrl+")) {
57
- const ch = k.slice(5);
58
- return data === `\x1b${ch}` || (ch.length === 1 && data === ch && data.charCodeAt(0) < 32);
59
- }
60
- switch (k) {
61
- case "escape":
62
- return data === "\x1b" || data === "\x1b\x1b";
63
- case "enter":
64
- case "return":
65
- return data === "\r" || data === "\n";
66
- case "tab":
67
- return data === "\t";
68
- case "backspace":
69
- return data === "\x7f" || data === "\b";
70
- case "up":
71
- return data === "\x1b[A" || data === "\x1bOA";
72
- case "down":
73
- return data === "\x1b[B" || data === "\x1bOB";
74
- case "left":
75
- return data === "\x1b[D" || data === "\x1bOD";
76
- case "right":
77
- return data === "\x1b[C" || data === "\x1bOC";
78
- case "home":
79
- return data === "\x1b[H" || data === "\x1bOH";
80
- case "end":
81
- return data === "\x1b[F" || data === "\x1bOF";
82
- case "pageup":
83
- return data === "\x1b[5~";
84
- case "pagedown":
85
- return data === "\x1b[6~";
86
- }
87
- // 单字符
88
- if (k.length === 1) return data === k;
89
- return false;
90
- }
91
-
92
- /** 按视觉宽度截断(中文算 2) */
93
- function truncateToWidth(s: string, max: number, ellipsis = "…"): string {
94
- if (max <= 0) return "";
95
- let w = 0;
96
- let out = "";
97
- for (const ch of s) {
98
- const cw = isWide(ch) ? 2 : 1;
99
- if (w + cw > max) return out + (ellipsis && w + 1 <= max ? ellipsis : "");
100
- out += ch;
101
- w += cw;
102
- }
103
- return out;
104
- }
105
-
106
- function isWide(ch: string): boolean {
107
- const code = ch.codePointAt(0) ?? 0;
108
- return code > 0x1100 && (
109
- (code >= 0x1100 && code <= 0x115f) ||
110
- (code >= 0x2e80 && code <= 0x9fff) ||
111
- (code >= 0xac00 && code <= 0xd7a3) ||
112
- (code >= 0xff00 && code <= 0xff60) ||
113
- (code >= 0xffe0 && code <= 0xffe6)
114
- );
115
- }
116
-
117
- function pad(s: string, width: number): string {
118
- let w = visualWidth(s);
119
- if (w >= width) return s;
120
- return s + " ".repeat(width - w);
121
- }
122
-
123
- /** 主题感知的 pad:把 [tag]...[/tag] 标记当作零宽,padding 补到目标可见宽度 */
124
- function visiblePad(s: string, width: number): string {
125
- const w = visibleWidthStrippingTheme(s);
126
- if (w >= width) return s;
127
- return s + " ".repeat(width - w);
128
- }
129
-
130
- /** 跳过 ANSI 转义序列和已知主题标签后算视觉宽度
131
- * - ANSI: \x1b[...m(零宽颜色码)
132
- * - 旧格式: [tag]...[/tag](只跳过白名单内的;其他 [..] 按字面文本计)
133
- */
134
- function visibleWidthStrippingTheme(s: string): number {
135
- let w = 0;
136
- let i = 0;
137
- while (i < s.length) {
138
- // ANSI 转义序列:\x1b[ ... m \x1b[ ... <字母>
139
- if (s[i] === "\x1b" && i + 1 < s.length && s[i + 1] === "[") {
140
- const close = s.indexOf("m", i + 2);
141
- if (close !== -1) { i = close + 1; continue; }
142
- // 其他 CSI 序列:结尾是某个字母
143
- const csiEnd = s.slice(i + 2).search(/[A-Za-z]/);
144
- if (csiEnd !== -1) { i = i + 2 + csiEnd + 1; continue; }
145
- }
146
- // 旧主题标签 [tag]
147
- if (s[i] === "[") {
148
- const close = s.indexOf("]", i + 1);
149
- if (close !== -1) {
150
- const inner = s.slice(i + 1, close);
151
- if (KNOWN_THEME_TAGS.has(inner) || (inner.startsWith("/") && KNOWN_THEME_TAGS.has(inner.slice(1)))) {
152
- i = close + 1;
153
- continue;
154
- }
155
- }
156
- }
157
- w += isWide(s[i]!) ? 2 : 1;
158
- i++;
159
- }
160
- return w;
161
- }
162
-
163
- /** 已知主题标签名集合。渲染器在 [name] 找不到主题色时会把整个 [name] 当字面文本输出 */
164
- const KNOWN_THEME_TAGS = new Set<string>([
165
- "accent", "warning", "dim", "success", "error", "muted", "text",
166
- "borderMuted", "border", "borderAccent",
167
- "background", "primary", "secondary",
168
- "toolTitle", "toolOutput", "toolBg",
169
- "customMessageBg", "userMessageBg", "thinking",
170
- "bold", "italic", "underline", "inverse",
171
- "selection", "comment", "keyword", "string", "number", "function",
172
- "variable", "type", "operator", "punctuation", "property",
173
- ]);
174
-
175
- function visualWidth(s: string): number {
176
- let w = 0;
177
- for (const ch of s) w += isWide(ch) ? 2 : 1;
178
- return w;
179
- }
180
-
181
- // ============================================================================
182
- // 数据加载
183
- // ============================================================================
184
-
185
- function buildProviders(ctx: ExtensionCommandContext, json: ModelsJson): { providers: ProviderRow[]; auth: Map<string, { hasKey: boolean; source?: string }> } {
186
- // 只看 models.json 里的自定义 provider;内置 provider 走 pi 的 /model,不在插件覆盖范围
187
- const customIds = Object.keys(json.providers).sort();
188
-
189
- // 本插件只管理 models.json 里的 url+apiKey 自定义 provider(无 OAuth)。
190
- // 直接从 json.providers[pid].apiKey 自检,避开 pi runtime 的多路径判断。
191
- // source 标识 key 来源:models.json_key(明文)、models.json_env($ENV)、models.json_command(!cmd)、empty(未设)。
192
- const auth = new Map<string, { hasKey: boolean; source?: string }>();
193
- for (const pid of customIds) {
194
- const apiKey = json.providers[pid]?.apiKey;
195
- auth.set(pid, inspectApiKey(apiKey));
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
- thinkingLevelMap: m.thinkingLevelMap,
213
- cost: m.cost,
214
- compat: m.compat,
215
- })),
216
- };
217
- });
218
- return { providers, auth };
219
- }
220
-
221
- // ============================================================================
222
- // Dashboard 组件
223
- // ============================================================================
224
-
225
- type Pane = "provider" | "model";
226
-
227
- class Dashboard {
228
- // exposed for testing
229
- static __test = true;
230
- private providers: ProviderRow[] = [];
231
- private auth = new Map<string, { hasKey: boolean; source?: string }>();
232
- private providerIndex = 0;
233
- private modelIndex = 0;
234
- private pane: Pane = "provider";
235
- private help = false;
236
- private initError?: string;
237
- private cachedWidth = -1;
238
- private cachedLines: string[] = [];
239
- private onClose: () => void;
240
- private theme: any;
241
- private ctx: ExtensionCommandContext;
242
- private json: ModelsJson = { providers: {} };
243
-
244
- constructor(
245
- ctx: ExtensionCommandContext,
246
- theme: any,
247
- onClose: () => void,
248
- ) {
249
- this.ctx = ctx;
250
- this.theme = theme;
251
- this.onClose = onClose;
252
- }
253
-
254
- /** 同步初始化:custom() 返回前必须先有数据,避免首帧 "(no providers found)" 闪烁 */
255
- init(): void {
256
- const path = getModelsJsonPath();
257
- let json: ModelsJson = { providers: {} };
258
- if (existsSync(path)) {
259
- try {
260
- const text = readFileSync(path, "utf8");
261
- const parsed = JSON.parse(text);
262
- if (parsed && typeof parsed === "object" && parsed.providers && typeof parsed.providers === "object") {
263
- json = parsed as ModelsJson;
264
- }
265
- } catch (err) {
266
- this.initError = `models.json 解析失败: ${err instanceof Error ? err.message : err}`;
267
- json = { providers: {} };
268
- }
269
- }
270
- this.json = json;
271
- this.initError = undefined;
272
- const built = buildProviders(this.ctx, json);
273
- this.providers = built.providers;
274
- this.auth = built.auth;
275
- if (this.providerIndex >= this.providers.length) this.providerIndex = Math.max(0, this.providers.length - 1);
276
- const curModels = this.providers[this.providerIndex]?.models ?? [];
277
- if (this.modelIndex >= curModels.length) this.modelIndex = Math.max(0, curModels.length - 1);
278
- }
279
-
280
- handleInput(data: string): void {
281
- if (matchesKey(data, "escape") || data === "q") {
282
- this.onClose();
283
- return;
284
- }
285
- if (matchesKey(data, "left") || matchesKey(data, "right")) {
286
- this.pane = this.pane === "provider" ? "model" : "provider";
287
- this.invalidate();
288
- return;
289
- }
290
- if (data === "?") {
291
- this.help = !this.help;
292
- this.invalidate();
293
- return;
294
- }
295
- // 空列表下"新增 provider"是唯一可执行动作,必须在导航块之前判定。
296
- // 否则 n 会落到 else if 链外的 if (items.length === 0) 分支被吞掉。
297
- if (data === "n") {
298
- if (this.pane === "provider") {
299
- void this.runForm(addProviderFlow);
300
- } else {
301
- // model 面板 n → 调用 addModelFlow(sync 拉不到时使用模板新增)
302
- const sel = this.providers[this.providerIndex];
303
- if (sel) void this.runForm(addModelFlow, sel.id);
304
- else this.ctx.ui.notify("No provider selected", "warning");
305
- }
306
- return;
307
- }
308
- // 导航(循环:第 1 个按 ↑ 跳最后,最后按 ↓ 跳第 1 个)
309
- const items = this.pane === "provider" ? this.providers : (this.providers[this.providerIndex]?.models ?? []);
310
- if (items.length === 0) {
311
- // 空列表:什么都不做
312
- } else if (matchesKey(data, "up") || data === "k") {
313
- this.setIndex(this.index() === 0 ? items.length - 1 : this.index() - 1);
314
- } else if (matchesKey(data, "down") || data === "j") {
315
- this.setIndex((this.index() + 1) % items.length);
316
- } else if (data === "g") {
317
- this.setIndex(0);
318
- } else if (data === "G") {
319
- this.setIndex(items.length - 1);
320
- } else if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
321
- // Enter 选中项的编辑(与原 'e' 行为一致)。model 仅允许 edit,不允许 new。
322
- if (this.pane === "provider" && this.providers[this.providerIndex]) {
323
- const id = this.providers[this.providerIndex].id;
324
- void this.runForm(editProviderFlow, id);
325
- } else {
326
- const prov = this.providers[this.providerIndex];
327
- const m = prov?.models[this.modelIndex];
328
- if (prov && m) void this.runForm(editModelFlow, prov.id, m.id);
329
- }
330
- } else if (data === "d") {
331
- const prov = this.providers[this.providerIndex];
332
- if (this.pane === "provider" && prov) {
333
- const id = prov.id;
334
- void this.runForm(deleteProviderFlow, id);
335
- } else if (prov && prov.models[this.modelIndex]) {
336
- const id = prov.models[this.modelIndex].id;
337
- const pid = prov.id;
338
- void this.runForm(deleteModelFlow, pid, id);
339
- }
340
- } else if (data === "y") {
341
- const sel = this.providers[this.providerIndex];
342
- if (sel) void this.runSync(sel.id);
343
- else this.ctx.ui.notify("No provider selected", "warning");
344
- } else if (data === "t" || data === "T") {
345
- void this.runTest(data === "T");
346
- }
347
- }
348
-
349
- private index(): number {
350
- return this.pane === "provider" ? this.providerIndex : this.modelIndex;
351
- }
352
- private setIndex(i: number): void {
353
- if (this.pane === "provider") this.providerIndex = i;
354
- else this.modelIndex = i;
355
- this.invalidate();
356
- }
357
-
358
- private async invalidateAndReload(): Promise<void> {
359
- // 重新读 models.json 并刷新 auth 缓存(仅自定义 provider)
360
- const json = await readModelsJson();
361
- this.json = json;
362
- this.auth = new Map();
363
- for (const pid of Object.keys(json.providers)) {
364
- this.auth.set(pid, inspectApiKey(json.providers[pid]?.apiKey));
365
- }
366
- this.invalidate();
367
- }
368
-
369
- /** 统一处理表单:先关掉当前 dashboard 让 editor 出来,form 跑完再重开。
370
- * 关键:ctx.ui.input modal dialog,dashboard 的 custom() 会顶住 editor,
371
- * 所以必须先 onClose(),等 custom() resolve 后才能正常跑 dialog。
372
- * form 完成后回调 onDone 重开 dashboard,否则 editor 暴露但用户预期在看 dashboard。
373
- * formFn 的签名是 (ctx, ...formArgs, onDone?);onDone 可选(缺了不崩,只 notify 不重开)。 */
374
- private async runForm(
375
- formFn: (ctx: ExtensionCommandContext, ...args: any[]) => Promise<void>,
376
- ...args: any[]
377
- ): Promise<void> {
378
- const ctx = this.ctx;
379
- this.onClose(); // 立刻关掉当前 custom()
380
- await Promise.resolve(); // custom() resolve
381
- try {
382
- await (formFn as any)(ctx, ...args, () => {
383
- void openDashboard(ctx);
384
- });
385
- } catch (err) {
386
- // 任何异常都不让 pi crash
387
- ctx.ui.notify(`表单异常: ${err instanceof Error ? err.message : err}`, "error");
388
- void openDashboard(ctx);
389
- } finally {
390
- // form return(Esc 中途取消)时 onDone 不会被调用,dashboard 永远不重开。
391
- // finally 兜底,确保 dashboard 总是恢复。
392
- void openDashboard(ctx);
393
- }
394
- }
395
-
396
- /** sync 的专用包装:runForm 是 `(ctx, ...args, onDone)` 风格,syncFlow 是 `(ctx, opts)` 风格 */
397
- private async runSync(sourceProviderId: string): Promise<void> {
398
- const ctx = this.ctx;
399
- this.onClose();
400
- await Promise.resolve();
401
- try {
402
- await syncFlow(ctx, { sourceProviderId, onDone: () => { void openDashboard(ctx); } });
403
- } catch (err) {
404
- ctx.ui.notify(`Sync error: ${err instanceof Error ? err.message : err}`, "error");
405
- } finally {
406
- void openDashboard(ctx);
407
- }
408
- }
409
-
410
- /** test 调测:t 测当前 model,T 测当前 provider 全部 model。保持 dashboard 不关,结束后重开。 */
411
- private async runTest(testAll: boolean): Promise<void> {
412
- const ctx = this.ctx as any;
413
- const provider = this.providers[this.providerIndex];
414
- if (!provider) {
415
- ctx.ui.notify("No provider selected", "warning");
416
- return;
417
- }
418
- if (testAll) {
419
- const modelIds = provider.models.map((m) => m.id);
420
- if (modelIds.length === 0) { ctx.ui.notify(`${provider.id} model`, "warning"); return; }
421
- ctx.ui.notify(`testing ${modelIds.length} model(s) of ${provider.id}...`, "info");
422
- const results = await testProvider({ ctx, provider: provider.id, modelIds, mode: "full", concurrency: 3 });
423
- let okCount = 0;
424
- for (const r of results) {
425
- if (r.ok) okCount++;
426
- }
427
- // 批量结果拼成一条 notify:逐条 notify 会被 showStatus 原地覆盖,只残留汇总行
428
- const summary = results.map((r) => formatTestResult(r)).join("\n\n") + `\n${provider.id}: ${okCount}/${results.length} ok`;
429
- ctx.ui.notify(summary, "info");
430
- } else {
431
- // t: 测当前 pane model(provider pane 测第一个 model;model pane 测当前 model)
432
- let modelId: string | undefined;
433
- if (this.pane === "model") {
434
- modelId = provider.models[this.modelIndex]?.id;
435
- } else {
436
- modelId = provider.models[0]?.id;
437
- }
438
- if (!modelId) { ctx.ui.notify(`${provider.id} model`, "warning"); return; }
439
- ctx.ui.notify(`testing ${provider.id}/${modelId}...`, "info");
440
- const r = await testModel({ ctx, provider: provider.id, model: modelId, mode: "full" });
441
- // 同上:统一 info 避免滞留
442
- ctx.ui.notify(formatTestResult(r), "info");
443
- }
444
- this.invalidate();
445
- }
446
-
447
- invalidate(): void {
448
- this.cachedWidth = -1;
449
- this.cachedLines = [];
450
- }
451
-
452
- render(width: number): string[] {
453
- if (this.cachedWidth === width && this.cachedLines.length > 0) return this.cachedLines;
454
- const th = this.theme;
455
- const lines: string[] = [];
456
-
457
- // 1. Header (title + stats)
458
- lines.push(this.renderTitleBar(width, th));
459
-
460
- if (this.initError) {
461
- lines.push(th.fg("error", ` ⚠ ${this.initError}`));
462
- lines.push(th.fg("dim", " 按 q 退出,修复 models.json 后 /providers 重开"));
463
- } else if (this.providers.length === 0) {
464
- lines.push(...this.renderEmptyState(th));
465
- } else {
466
- // 2. Body: 两栏
467
- lines.push("");
468
- const colWidth = Math.max(24, Math.floor((width - 3) / 2));
469
- const leftLines = this.renderProviderColumn(colWidth, th);
470
- const rightLines = this.renderModelColumn(colWidth, th);
471
- const rows = Math.max(leftLines.length, rightLines.length);
472
- const sep = th.fg("borderMuted", " │ ");
473
- for (let r = 0; r < rows; r++) {
474
- const l = leftLines[r] ?? "";
475
- const rr = rightLines[r] ?? "";
476
- lines.push(visiblePad(l, colWidth) + sep + rr);
477
- }
478
-
479
- // 3. Detail
480
- lines.push("");
481
- lines.push(...this.renderDetail(width, th));
482
- }
483
-
484
- // 4. Footer
485
- lines.push(th.fg("borderMuted", "─".repeat(width)));
486
- if (this.help) {
487
- lines.push(...this.renderHelp(width, th));
488
- } else if (this.providers.length === 0) {
489
- // 空态:导航/编辑/同步/删除 均无意义,只保留有效动作
490
- lines.push(th.fg("dim", " n add first provider · ? help · q close"));
491
- } else {
492
- const parts = ["↑↓/jk nav", "←→ pane"];
493
- if (this.pane === "provider") parts.push("n new", "Enter edit", "y sync");
494
- else parts.push("n new", "Enter edit", "y sync", "t test", "T test-all");
495
- parts.push("d del", "? help", "q close");
496
- lines.push(th.fg("dim", " " + parts.join(" · ")));
497
- }
498
- this.cachedWidth = width;
499
- this.cachedLines = lines;
500
- return lines;
501
- }
502
-
503
- /** title bar:左侧包名+粗体,右侧 stats(providers/models/authed) */
504
- private renderTitleBar(width: number, th: any): string {
505
- const totalModels = this.providers.reduce((s, p) => s + p.models.length, 0);
506
- const authed = Array.from(this.auth.values()).filter(a => a?.hasKey).length;
507
- const stats = this.providers.length === 0
508
- ? "no providers"
509
- : `${this.providers.length}P · ${totalModels}M${authed > 0 ? ` · ${authed}✓` : ""}`;
510
- const title = th.fg("accent", th.bold(" provider-manager "));
511
- const right = th.fg("dim", " " + stats + " ");
512
- const titleW = 18; // " provider-manager " visible length
513
- const rightW = visibleWidthStrippingTheme(right);
514
- const fill = Math.max(2, width - titleW - rightW);
515
- return title + th.fg("borderMuted", "─".repeat(fill)) + right;
516
- }
517
-
518
- /** 无 provider 时的空态提示 */
519
- private renderEmptyState(th: any): string[] {
520
- const out: string[] = [];
521
- out.push("");
522
- out.push(th.fg("dim", " ┌──────────────────────────────────────────────────┐"));
523
- out.push(th.fg("dim", " │ (no providers found) │"));
524
- out.push(th.fg("dim", " │ │"));
525
- out.push(th.fg("dim", " │ Press ") + th.fg("accent", "n") + th.fg("dim", " to add the first provider. │"));
526
- out.push(th.fg("dim", " │ Or check ~/.pi/agent/models.json. │"));
527
- out.push(th.fg("dim", " └──────────────────────────────────────────────────┘"));
528
- return out;
529
- }
530
-
531
- private renderProviderColumn(width: number, th: any): string[] {
532
- const lines: string[] = [];
533
- const totalModels = this.providers.reduce((s, p) => s + p.models.length, 0);
534
- const authed = Array.from(this.auth.values()).filter(a => a?.hasKey).length;
535
- const stats = ` ${this.providers.length}·${authed}✓ ${totalModels}m `;
536
- // ▸ 之前硬编码在 headText 里,inactive 时 trimStart() 不能去掉它(不是空白),导致头部 2 空格+▸ 与下面
537
- // cursor 行的 2 空格+内容 错 1 个字符。现在按 pane 动态生成。
538
- const headActive = this.pane === "provider";
539
- const headPrefix = headActive ? "▸ " : " ";
540
- const headBase = "Providers";
541
- // 先按 plain 文本 truncate,再 th.fg 整行包色(同 model 列)
542
- const headPlain = truncateToWidth(headPrefix + headBase + stats, width);
543
- const head = (headActive ? th.fg("accent", th.bold(headPlain)) : th.fg("muted", th.bold(headPlain)));
544
- lines.push(head);
545
- // 下划线长度 = head 实际可见宽度
546
- lines.push(th.fg("borderMuted", "─".repeat(Math.min(width, headPrefix.length + headBase.length + stats.length))));
547
- this.providers.forEach((p, i) => {
548
- const sel = i === this.providerIndex;
549
- const isActivePane = sel && this.pane === "provider";
550
- const arrow = isActivePane ? th.fg("accent", "▸ ") : " ";
551
- const nameTh = sel ? th.bold(p.id) : p.id;
552
- // 认证状态图标:✓ ( key) / ✗ (无 key) / 空格 (无 status)
553
- const auth = this.auth.get(p.id);
554
- let authIcon = " ";
555
- if (auth) authIcon = auth.hasKey ? th.fg("success", "✓ ") : th.fg("error", "✗ ");
556
- // model 数量
557
- const cnt = th.fg("dim", ` ${p.models.length}m`);
558
- // 0 model 提示
559
- const warn = p.models.length === 0 ? th.fg("warning", " ⚠") : "";
560
- const line = arrow + nameTh + authIcon + cnt + warn;
561
- lines.push(visiblePad(line, width));
562
- });
563
- return lines;
564
- }
565
-
566
- private renderModelColumn(width: number, th: any): string[] {
567
- const lines: string[] = [];
568
- const provider = this.providers[this.providerIndex];
569
- const models = provider?.models ?? [];
570
- const rCount = models.filter(m => m.reasoning).length;
571
- const iCount = models.filter(m => m.input.includes("image")).length;
572
- const stats = models.length > 0 ? ` ${models.length}m · ${rCount}R · ${iCount}I ` : " 0m ";
573
- // ▸ 由 pane 决定,不在 headPlain 里。同 provider 列。
574
- const isHeadActive = this.pane === "model" && !!provider;
575
- const headPrefix = isHeadActive ? "▸ " : " ";
576
- const headBase = provider ? `Models (${provider.id})` : "Models";
577
- // 先按 plain 文本 truncate(避免 ANSI 字符撑爆宽度),最后整行包色
578
- const headPlain = truncateToWidth(headPrefix + headBase + stats, width);
579
- const headColored = isHeadActive ? th.fg("accent", th.bold(headPlain)) : th.fg("muted", th.bold(headPlain));
580
- lines.push(headColored);
581
- // 下划线长度 = head 可见宽度
582
- lines.push(th.fg("borderMuted", "─".repeat(Math.min(width, headPrefix.length + headBase.length + stats.length))));
583
-
584
- if (models.length === 0) {
585
- lines.push(th.fg("dim", " (no models)"));
586
- lines.push(th.fg("dim", " Press ") + th.fg("accent", "y") + th.fg("dim", " to sync from remote"));
587
- return lines;
588
- }
589
- models.forEach((m, i) => {
590
- const sel = i === this.modelIndex;
591
- const isActivePane = sel && this.pane === "model";
592
- // plain text,末尾才 th.fg 整行包色(避免 ANSI 被 truncateToWidth 计入宽度)
593
- const arrow = isActivePane ? "▸ " : " ";
594
- const rFlag = m.reasoning ? "R" : "-";
595
- const iFlag = m.input.includes("image") ? "I" : "-";
596
- const flagStr = ` [${rFlag}${iFlag}]`;
597
- const ctx2 = m.contextWindow ? ` ${formatNum(m.contextWindow)}c` : "";
598
- const max2 = m.maxTokens ? ` ${formatNum(m.maxTokens)}m` : "";
599
- const raw = arrow + m.id + flagStr + ctx2 + max2;
600
- const line = truncateToWidth(raw, width);
601
- lines.push(sel ? th.fg("accent", line) : line);
602
- });
603
- return lines;
604
- }
605
-
606
- private renderDetail(width: number, th: any): string[] {
607
- const lines: string[] = [];
608
- if (this.pane === "provider") {
609
- const p = this.providers[this.providerIndex];
610
- if (!p) return [th.fg("dim", " (no provider selected)")];
611
- const auth = this.auth.get(p.id);
612
- const authIcon = auth
613
- ? (auth.hasKey ? th.fg("success", "✓ ") : th.fg("error", "✗ "))
614
- : th.fg("dim", " ");
615
- // 大标题
616
- lines.push(th.fg("accent", th.bold(` ${authIcon} Provider: `)) + th.bold(p.id));
617
- lines.push("");
618
- // Identity
619
- lines.push(th.fg("muted", " Identity"));
620
- lines.push(` displayName: ${p.displayName || th.fg("dim", "(unset)")}`);
621
- lines.push(` source: models.json (custom)`);
622
- lines.push(` models: ${p.models.length}`);
623
- // raw config
624
- const raw = this.json?.providers?.[p.id] as any;
625
- if (raw) {
626
- lines.push("");
627
- lines.push(th.fg("muted", " Endpoint"));
628
- lines.push(` baseUrl: ${raw.baseUrl || th.fg("dim", "(unset)")}`);
629
- lines.push(` api: ${raw.api || th.fg("dim", "(unset)")}`);
630
- if (raw.proxy) lines.push(` proxy: ${raw.proxy}`);
631
- lines.push("");
632
- lines.push(th.fg("muted", " Auth"));
633
- lines.push(` apiKey: ${maskApiKey(raw.apiKey)}`);
634
- lines.push(` authHeader: ${raw.authHeader ? "yes" : "no"}`);
635
- if (auth) {
636
- // 自检:仅描述 models.json apiKey 字段状态(不是 pi 的认证是否有效;那是 t/T 测的)
637
- const statusText = auth.hasKey ? "set" : "empty";
638
- const statusColor = auth.hasKey ? th.fg("success", "✓ set") : th.fg("warning", "✗ empty");
639
- lines.push(` apiKey status: ${statusColor}${auth.source && auth.source !== "empty" ? th.fg("dim", " (" + auth.source + ")") : ""}`);
640
- }
641
- }
642
- } else {
643
- const p = this.providers[this.providerIndex];
644
- const m = p?.models[this.modelIndex];
645
- if (!m) return [th.fg("dim", " (no model selected)")];
646
- lines.push(th.fg("accent", th.bold(` Model: `)) + `${p.id} / ${m.id}`);
647
- lines.push("");
648
- lines.push(th.fg("muted", " Capabilities"));
649
- lines.push(` reasoning: ${m.reasoning ? th.fg("accent", "yes") : th.fg("dim", "no")}`);
650
- lines.push(` input: ${m.input.join(", ") || th.fg("dim", "(none)")}`);
651
- lines.push("");
652
- lines.push(th.fg("muted", " Limits"));
653
- lines.push(` context: ${m.contextWindow?.toLocaleString() ?? th.fg("dim", "?")}`);
654
- lines.push(` max output: ${m.maxTokens?.toLocaleString() ?? th.fg("dim", "?")}`);
655
- // thinking level map:单行显示 enabled 的 level 名字(`low, medium, max`)。无任何 enabled 时跳过
656
- // 详情面板真值来自 m.thinkingLevelMap(buildProviders 已从 ModelConfig 透传)
657
- const tlm = m.thinkingLevelMap;
658
- if (tlm && typeof tlm === "object") {
659
- const enabled = (Object.entries(tlm) as [string, string | null][])
660
- .filter(([, v]) => v !== null && v !== undefined)
661
- .map(([k]) => k);
662
- if (enabled.length) {
663
- lines.push("");
664
- lines.push(` Thinking levels: ${th.fg("text", enabled.join(", "))}`);
665
- }
666
- }
667
- // cost
668
- const cost = m.cost;
669
- if (cost) {
670
- lines.push("");
671
- lines.push(th.fg("muted", " Cost"));
672
- lines.push(` input: $${cost.input}/M`);
673
- lines.push(` output: $${cost.output}/M`);
674
- if (cost.cacheRead) lines.push(` cache read: $${cost.cacheRead}/M`);
675
- if (cost.cacheWrite) lines.push(` cache write: $${cost.cacheWrite}/M`);
676
- }
677
- // compat:Zhipu GLM 等 OpenAI-compat 网关拒收 role:"developer"(会返 422)。为 false 时 pi 用 system role。
678
- const compat = m.compat;
679
- if (compat && typeof compat === "object") {
680
- lines.push("");
681
- lines.push(th.fg("muted", " Compat"));
682
- if (typeof (compat as any).supportsDeveloperRole === "boolean") {
683
- const sdr = (compat as any).supportsDeveloperRole;
684
- lines.push(` supportsDeveloperRole: ${sdr ? th.fg("success", "yes") : th.fg("warning", "no")}`);
685
- }
686
- }
687
- }
688
- return lines.map((l) => truncateToWidth(l, width));
689
- }
690
-
691
- private renderHelp(width: number, th: any): string[] {
692
- const lines: string[] = [
693
- th.fg("accent", "Key bindings"),
694
- " ↑/↓ or j/k navigate in current pane",
695
- " g / G jump to top / bottom",
696
- " ← / → switch between Providers and Models pane",
697
- " Enter edit selected provider / model",
698
- " d delete (with confirm)",
699
- " y sync fetch remote models for selected provider",
700
- " ? toggle this help",
701
- " q / Esc close dashboard",
702
- ];
703
- // 按面板增补特有项
704
- if (this.pane === "provider") {
705
- lines.splice(5, 0, " n new provider (model 仍走 sync)");
706
- } else {
707
- lines.splice(5, 0, " n new model manually (sync 拉不到时;走 defaultModel 模板)", " t / T test current model / test all in provider");
708
- }
709
- lines.push("", th.fg("dim", " y sync"));
710
- return lines.map((l) => truncateToWidth(l, width));
711
- }
712
- }
713
-
714
- function formatNum(n: number): string {
715
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
716
- if (n >= 1_000) return (n / 1_000).toFixed(0) + "k";
717
- return String(n);
718
- }
719
-
720
- /**
721
- * 检查 models.json 里 provider.apiKey 的状态。
722
- * 返回 { hasKey, source }:source 标识 key 的来源类型。
723
- * - "models_json_key" 明文 API key
724
- * - "models_json_env" $ENV_VAR 或 ${ENV_VAR} 插值
725
- * - "models.json_command" !shell-command 动态取 key
726
- * - "empty" 未设
727
- */
728
- function inspectApiKey(apiKey: unknown): { hasKey: boolean; source?: string } {
729
- if (typeof apiKey !== "string" || apiKey.length === 0) {
730
- return { hasKey: false, source: "empty" };
731
- }
732
- // !command 动态取 key
733
- if (apiKey.startsWith("!")) {
734
- return { hasKey: true, source: "models.json_command" };
735
- }
736
- // $ENV 或 ${ENV}
737
- if (/^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(apiKey)) {
738
- return { hasKey: true, source: "models.json_env" };
739
- }
740
- return { hasKey: true, source: "models_json_key" };
741
- }
742
-
743
- // ============================================================================
744
- // 对外 API
745
- // ============================================================================
746
-
747
- export { Dashboard }; // for unit tests
748
-
749
-
750
- /** 打开 Dashboard(TUI 模式);非 TUI 走 fallback */
751
- export async function openDashboard(ctx: ExtensionCommandContext): Promise<void> {
752
- if (ctx.mode !== "tui") {
753
- ctx.ui.notify("Dashboard requires TUI mode. Try /providers ls in this mode.", "error");
754
- return;
755
- }
756
- await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
757
- const dash = new Dashboard(ctx, theme, () => done());
758
- dash.init(); // 同步初始化,首帧就有数据
759
- return dash;
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
+ }