@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/components.ts ADDED
@@ -0,0 +1,696 @@
1
+ /**
2
+ * components.ts — 可复用的 TUI 组件
3
+ *
4
+ * 纯字符串渲染(仿 ui.ts 的 Dashboard 风格),零外部 pi-tui 依赖。
5
+ * 所有组件用 ctx.ui.custom((_tui, theme, _kb, done) => component) 接入。
6
+ */
7
+
8
+ // ============================================================================
9
+ // Key 匹配工具
10
+ // ============================================================================
11
+
12
+ export function matchesKey(data: string, key: string): boolean {
13
+ const k = key.toLowerCase();
14
+ if (k.startsWith("ctrl+")) {
15
+ const ch = k.slice(5);
16
+ return data === `\x1b${ch}` || (ch.length === 1 && data === ch && data.charCodeAt(0) < 32);
17
+ }
18
+ switch (k) {
19
+ case "escape": return data === "\x1b" || data === "\x1b\x1b";
20
+ case "enter":
21
+ case "return": return data === "\r" || data === "\n";
22
+ case "tab": return data === "\t"; // \x1b[Z = Shift+Tab,走 shift+tab
23
+ case "backspace": return data === "\x7f" || data === "\b";
24
+ case "shift+tab": return data === "\x1b[Z" || data === "\x1b[10;5~";
25
+ case "up": return data === "\x1b[A" || data === "\x1bOA";
26
+ case "down": return data === "\x1b[B" || data === "\x1bOB";
27
+ case "left": return data === "\x1b[D" || data === "\x1bOD";
28
+ case "right": return data === "\x1b[C" || data === "\x1bOC";
29
+ case "home": return data === "\x1b[H" || data === "\x1bOH";
30
+ case "end": return data === "\x1b[F" || data === "\x1bOF";
31
+ case "pageup": return data === "\x1b[5~";
32
+ case "pagedown": return data === "\x1b[6~";
33
+ }
34
+ if (k.length === 1) return data === k;
35
+ return false;
36
+ }
37
+
38
+ // ============================================================================
39
+ // ModelChecklist — 多选 checklist(sync 用)
40
+ // ============================================================================
41
+
42
+ export type ChecklistItem = {
43
+ id: string;
44
+ label?: string; // 副标题/备注
45
+ disabled?: boolean; // true = 显示但不让选
46
+ };
47
+
48
+ export class ModelChecklist {
49
+ private items: ChecklistItem[];
50
+ private selected: Set<string>;
51
+ private cursor = 0;
52
+ private title: string;
53
+ private theme: any;
54
+ private onConfirm: (selected: string[]) => void;
55
+ private onCancel: () => void;
56
+ private cachedWidth = -1;
57
+ private cachedLines: string[] = [];
58
+
59
+ constructor(opts: {
60
+ title: string;
61
+ items: ChecklistItem[];
62
+ theme: any;
63
+ preSelect?: (item: ChecklistItem) => boolean; // 默认全选时筛掉不想要的
64
+ onConfirm: (selected: string[]) => void;
65
+ onCancel: () => void;
66
+ }) {
67
+ this.title = opts.title;
68
+ this.items = opts.items;
69
+ this.theme = opts.theme;
70
+ this.onConfirm = opts.onConfirm;
71
+ this.onCancel = opts.onCancel;
72
+ this.selected = new Set();
73
+ for (const it of opts.items) {
74
+ if (it.disabled) continue;
75
+ if (opts.preSelect && !opts.preSelect(it)) continue;
76
+ this.selected.add(it.id);
77
+ }
78
+ }
79
+
80
+ handleInput(data: string): void {
81
+ if (matchesKey(data, "escape") || data === "q") {
82
+ this.onCancel();
83
+ return;
84
+ }
85
+ if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
86
+ this.onConfirm([...this.selected]);
87
+ return;
88
+ }
89
+ if (data === " ") {
90
+ const it = this.items[this.cursor];
91
+ if (it && !it.disabled) {
92
+ if (this.selected.has(it.id)) this.selected.delete(it.id);
93
+ else this.selected.add(it.id);
94
+ this.invalidate();
95
+ }
96
+ return;
97
+ }
98
+ if (matchesKey(data, "down") || data === "j") {
99
+ this.cursor = Math.min(this.cursor + 1, this.items.length - 1);
100
+ this.invalidate();
101
+ } else if (matchesKey(data, "up") || data === "k") {
102
+ this.cursor = Math.max(this.cursor - 1, 0);
103
+ this.invalidate();
104
+ } else if (data === "a") {
105
+ // toggle all (only non-disabled)
106
+ const allIds = this.items.filter((it) => !it.disabled).map((it) => it.id);
107
+ if (this.selected.size === allIds.length) this.selected.clear();
108
+ else this.selected = new Set(allIds);
109
+ this.invalidate();
110
+ } else if (data === "g") {
111
+ // jump to top
112
+ this.cursor = 0; this.invalidate();
113
+ } else if (data === "G") {
114
+ // jump to bottom
115
+ this.cursor = this.items.length - 1; this.invalidate();
116
+ } else if (data === "i") {
117
+ // invert selection
118
+ const next = new Set<string>();
119
+ for (const it of this.items) {
120
+ if (it.disabled) continue;
121
+ if (!this.selected.has(it.id)) next.add(it.id);
122
+ }
123
+ this.selected = next;
124
+ this.invalidate();
125
+ }
126
+ }
127
+
128
+ invalidate(): void { this.cachedWidth = -1; this.cachedLines = []; }
129
+
130
+ render(width: number): string[] {
131
+ if (this.cachedWidth === width && this.cachedLines.length > 0) return this.cachedLines;
132
+ const th = this.theme;
133
+ const lines: string[] = [];
134
+
135
+ // Header
136
+ const head = th.fg("accent", th.bold(` ${this.title} `)) + th.fg("borderMuted", "─".repeat(Math.max(0, width - this.title.length - 4)));
137
+ lines.push(head);
138
+ const total = this.items.length;
139
+ const sel = this.selected.size;
140
+ const disabled = this.items.filter((it) => it.disabled).length;
141
+ const summary = ` ${sel}/${total} selected${disabled ? ` (${disabled} existing, skipped)` : ""} `;
142
+ lines.push(th.fg("dim", summary));
143
+ lines.push("");
144
+
145
+ // List
146
+ if (total === 0) {
147
+ lines.push(th.fg("dim", " (no models to choose from)"));
148
+ } else {
149
+ for (let i = 0; i < this.items.length; i++) {
150
+ const it = this.items[i];
151
+ const isCursor = i === this.cursor;
152
+ const arrow = isCursor ? th.fg("accent", "▸ ") : " ";
153
+ let box: string;
154
+ if (it.disabled) box = th.fg("dim", "[skip]");
155
+ else if (this.selected.has(it.id)) box = th.fg("success", "[√]");
156
+ else box = th.fg("dim", "[ ]");
157
+ const id = isCursor ? th.bold(it.id) : it.id;
158
+ const sub = it.label ? " " + th.fg("muted", it.label) : "";
159
+ lines.push(truncateForRender(`${arrow}${box} ${id}${sub}`, width));
160
+ }
161
+ }
162
+
163
+ lines.push("");
164
+ lines.push(th.fg("borderMuted", "─".repeat(width)));
165
+ lines.push(th.fg("dim", " Space toggle · ↑↓/jk nav · g/G top/bot · a all · i invert · Enter apply · Esc cancel"));
166
+ this.cachedWidth = width;
167
+ this.cachedLines = lines;
168
+ return lines;
169
+ }
170
+ }
171
+
172
+ /** 给 render 用的截断(不剥主题,简化版;与 ui.ts 的 truncateToWidth 行为一致) */
173
+ function truncateForRender(s: string, width: number): string {
174
+ if (width <= 0) return "";
175
+ let w = 0;
176
+ let out = "";
177
+ for (const ch of s) {
178
+ const cw = isWideChar(ch) ? 2 : 1;
179
+ if (w + cw > width) return out + (w + 1 <= width ? "…" : "");
180
+ out += ch;
181
+ w += cw;
182
+ }
183
+ return out;
184
+ }
185
+
186
+ function isWideChar(ch: string): boolean {
187
+ const code = ch.codePointAt(0) ?? 0;
188
+ return code > 0x1100 && (
189
+ (code >= 0x1100 && code <= 0x115f) ||
190
+ (code >= 0x2e80 && code <= 0x9fff) ||
191
+ (code >= 0xac00 && code <= 0xd7a3) ||
192
+ (code >= 0xff00 && code <= 0xff60) ||
193
+ (code >= 0xffe0 && code <= 0xffe6)
194
+ );
195
+ }
196
+
197
+ // ============================================================================
198
+ // FormEditor — 通用 TUI 单页表单编辑器
199
+ // ============================================================================
200
+
201
+ export type FormFieldType = "text" | "secret" | "select" | "number" | "readonly" | "json" | "levelmap" | "multiselect";
202
+
203
+ /** pi 的 thinking level 列表。levelmap 字段的勾选/取消就按这个顺序。 */
204
+ export const PI_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
205
+
206
+ export type FormField = {
207
+ key: string;
208
+ label: string;
209
+ type: FormFieldType;
210
+ options?: string[]; // for select
211
+ placeholder?: string;
212
+ hint?: string; // 显示在 value 后面的小提示
213
+ validate?: (value: unknown) => string | null; // 返回错误信息
214
+ render?: (value: unknown) => string; // 自定义显示(覆盖默认)
215
+ };
216
+
217
+ export class FormEditor<T extends Record<string, unknown>> {
218
+ private fields: FormField[];
219
+ private values: T;
220
+ private original: T;
221
+ private draft = "";
222
+ private draftIsOriginal = true; // draft == currentValue,未编辑过
223
+ private cursor = 0;
224
+ private levelmapCursor = 0; // levelmap 字段内的 row 位置(0-6)
225
+ private selectCursor = 0; // select 字段内的 options 位置
226
+ private multiselectCursor = 0; // multiselect 字段内的 options 位置
227
+ private editing = false; // 当前非输入字段是否在 edit 模式(Enter 进入、Enter/Space 退出)
228
+ private error: string | null = null;
229
+ private theme: any;
230
+ private title: string;
231
+ private onSave: (values: T) => void;
232
+ private onCancel: () => void;
233
+ private cachedWidth = -1;
234
+ private cachedLines: string[] = [];
235
+
236
+ constructor(opts: {
237
+ title: string;
238
+ fields: FormField[];
239
+ initial: T;
240
+ theme: any;
241
+ onSave: (values: T) => void;
242
+ onCancel: () => void;
243
+ }) {
244
+ this.title = opts.title;
245
+ this.fields = opts.fields;
246
+ this.values = JSON.parse(JSON.stringify(opts.initial)) as T;
247
+ this.original = JSON.parse(JSON.stringify(opts.initial)) as T;
248
+ this.theme = opts.theme;
249
+ this.onSave = opts.onSave;
250
+ this.onCancel = opts.onCancel;
251
+ this.draft = this.currentValueAsString();
252
+ this.draftIsOriginal = true;
253
+ }
254
+
255
+ private currentValueAsString(): string {
256
+ const f = this.fields[this.cursor];
257
+ const v = this.values[f.key];
258
+ if (v === undefined || v === null) return "";
259
+ if (f.type === "secret") return v ? "••••" + String(v).slice(-2) : "";
260
+ if (f.type === "json") return v === null ? "" : JSON.stringify(v);
261
+ if (f.type === "levelmap") {
262
+ const m = (v && typeof v === "object") ? v as Record<string, string | null> : {};
263
+ const enabled = PI_LEVELS.filter((l) => m[l] != null);
264
+ return enabled.length ? enabled.join(", ") : "(none)";
265
+ }
266
+ if (f.type === "multiselect") {
267
+ const arr = Array.isArray(v) ? v as string[] : [];
268
+ return arr.length ? arr.join(", ") : "(none)";
269
+ }
270
+ return String(v);
271
+ }
272
+
273
+ private commitDraft(): { ok: boolean } {
274
+ const f = this.fields[this.cursor];
275
+ if (f.type === "readonly") return { ok: true };
276
+ if (f.type === "levelmap") {
277
+ // 保证 7 个 level 都写进 values:勾选的写 level 名,未勾选的写 null(避岀 cycle)
278
+ const cur = ((this.values as any)[f.key] ?? {}) as Record<string, string | null>;
279
+ const normalized: Record<string, string | null> = {};
280
+ for (const lv of PI_LEVELS) {
281
+ const v = cur[lv];
282
+ normalized[lv] = v === undefined ? null : v;
283
+ }
284
+ (this.values as any)[f.key] = normalized;
285
+ return { ok: true };
286
+ }
287
+ if (f.type === "multiselect") return { ok: true }; // 直接操作 values,不需 draft
288
+ const raw = this.draft;
289
+ if (f.type === "number") {
290
+ // 严格校验:draft 必须全数字(或空 → 走默认值 0)
291
+ if (!/^\d+$/.test(raw)) { this.error = `${f.label}: must be a non-negative integer`; return { ok: false }; }
292
+ const n = parseInt(raw, 10);
293
+ if (f.validate) {
294
+ const err = f.validate(n);
295
+ if (err) { this.error = `${f.label}: ${err}`; return { ok: false }; }
296
+ }
297
+ (this.values as any)[f.key] = n;
298
+ } else if (f.type === "json") {
299
+ try {
300
+ const parsed = raw.trim() ? JSON.parse(raw) : null;
301
+ if (f.validate) {
302
+ const err = f.validate(parsed);
303
+ if (err) { this.error = `${f.label}: ${err}`; return { ok: false }; }
304
+ }
305
+ (this.values as any)[f.key] = parsed;
306
+ } catch (err) {
307
+ this.error = `${f.label}: invalid JSON (${err instanceof Error ? err.message : err})`;
308
+ return { ok: false };
309
+ }
310
+ } else if (f.type === "select") {
311
+ if (raw && !f.options?.includes(raw)) {
312
+ // 宽松容错:true/yes → options 中的"yes";false/no → "no";其它字符串报错
313
+ if (raw === "true" || raw === "yes") { (this.values as any)[f.key] = (f.options ?? []).find((o) => o === "yes") ?? raw; }
314
+ else if (raw === "false" || raw === "no") { (this.values as any)[f.key] = (f.options ?? []).find((o) => o === "no") ?? raw; }
315
+ else { this.error = `${f.label}: must be one of ${(f.options ?? []).join(", ")}`; return { ok: false }; }
316
+ } else if (raw) { (this.values as any)[f.key] = raw; }
317
+ if (f.validate) { const err = f.validate(raw); if (err) { this.error = `${f.label}: ${err}`; return { ok: false }; } }
318
+ } else { // text, secret
319
+ if (f.validate) { const err = f.validate(raw); if (err) { this.error = `${f.label}: ${err}`; return { ok: false }; } }
320
+ (this.values as any)[f.key] = raw;
321
+ }
322
+ this.error = null;
323
+ return { ok: true };
324
+ }
325
+
326
+ private move(delta: number): void {
327
+ const result = this.commitDraft();
328
+ this.cursor = (this.cursor + delta + this.fields.length) % this.fields.length;
329
+ // levelmap/select 字段专用的 sub-cursor:进入/离开都重置
330
+ // levelmap 始终 0;select 设为当前 value 在 options 里的 index(找不到则 0)
331
+ this.levelmapCursor = 0;
332
+ const f = this.fields[this.cursor];
333
+ if (f?.type === "select" && f.options) {
334
+ const cur = this.values[f.key] as string | undefined;
335
+ const idx = cur ? f.options.indexOf(cur) : -1;
336
+ this.selectCursor = idx >= 0 ? idx : 0;
337
+ } else {
338
+ this.selectCursor = 0;
339
+ }
340
+ this.draft = this.currentValueAsString();
341
+ this.draftIsOriginal = true;
342
+ // 仅在 commit 成功时清 error;失败时保留让用户看到
343
+ if (result.ok) this.error = null;
344
+ this.invalidate();
345
+ }
346
+
347
+ /** levelmap 字段:切换当前 row 的 enable/disable。enable 用 level 名做 value,disable 写 null(避免该 level 进入 cycle)。 */
348
+ private toggleLevelmapRow(key: string): void {
349
+ const level = PI_LEVELS[this.levelmapCursor];
350
+ const cur = ((this.values as any)[key] ?? {}) as Record<string, string | null>;
351
+ const next: Record<string, string | null> = { ...cur };
352
+ // 保证所有 7 个 level 都有 key(未勾选的全部以 null 补上,进不了 cycle)
353
+ for (const lv of PI_LEVELS) {
354
+ if (next[lv] === undefined) next[lv] = null;
355
+ }
356
+ if (cur[level]) next[level] = null; // 当前 enabled → 写 null(退出 cycle)
357
+ else next[level] = level; // 当前 disabled → enable,value = level 名
358
+ (this.values as any)[key] = next;
359
+ this.draftIsOriginal = false;
360
+ this.invalidate();
361
+ }
362
+
363
+ handleInput(data: string): void {
364
+ const f0 = this.fields[this.cursor];
365
+ const isNonInput = f0?.type === "select" || f0?.type === "levelmap" || f0?.type === "multiselect";
366
+ const isTypeable = f0 && (f0.type === "text" || f0.type === "secret" || f0.type === "number" || f0.type === "json");
367
+
368
+ // Esc / q → 取消整个 form(仅当不在 typeable 字段里输入字母 q/q)
369
+ // 在 text/secret/number/json 字段里输入 q 应该被当作字符(apiKey 含 q 不会被取消)
370
+ if (matchesKey(data, "escape") || (data === "q" && !isTypeable)) {
371
+ // edit 模式下 Esc = 退出 edit 模式(commit 当前值)
372
+ if (this.editing) {
373
+ this.commitDraft();
374
+ this.editing = false;
375
+ this.invalidate();
376
+ return;
377
+ }
378
+ this.onCancel();
379
+ return;
380
+ }
381
+
382
+ // s → 保存整个 form(仅当不在 typeable 字段里输入字母 s)
383
+ // 在 text/secret/number/json 字段里输入 s 应该被当作字符(apiKey 含 s 不会被保存)
384
+ if (data === "s" && !isTypeable) {
385
+ const result = this.commitDraft();
386
+ if (!result.ok) { this.invalidate(); return; }
387
+ this.onSave(this.values);
388
+ return;
389
+ }
390
+
391
+ // e → 非输入字段进入 edit 模式(保持整体风格一致:单键操作)
392
+ if ((data === "e" || data === "E") && isNonInput && !this.editing) {
393
+ this.editing = true;
394
+ this.invalidate();
395
+ return;
396
+ }
397
+ // Enter 行为
398
+ // edit 模式下 → 退出 edit 模式(commit 当前值)
399
+ // 输入字段 → 提交 + 移动到下一字段
400
+ // 其他(readonly)→ 提交整个 form 并保存
401
+ if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
402
+ if (this.editing && isNonInput) {
403
+ this.commitDraft();
404
+ this.editing = false;
405
+ this.invalidate();
406
+ return;
407
+ }
408
+ if (isTypeable) {
409
+ // 输入字段:commit draft + 移动到下一字段
410
+ const result = this.commitDraft();
411
+ if (!result.ok) { this.invalidate(); return; }
412
+ this.move(1);
413
+ this.editing = false;
414
+ return;
415
+ }
416
+ // readonly 字段:Enter 什么都不做(用 s 保存)
417
+ return;
418
+ }
419
+
420
+ // edit 模式下:↑↓/j/k 在 options 内移动,Space pick
421
+ if (this.editing && isNonInput) {
422
+ if (f0!.type === "levelmap") {
423
+ if (matchesKey(data, "down") || data === "j") {
424
+ if (this.levelmapCursor < 6) { this.levelmapCursor++; this.invalidate(); }
425
+ return;
426
+ }
427
+ if (matchesKey(data, "up") || data === "k") {
428
+ if (this.levelmapCursor > 0) { this.levelmapCursor--; this.invalidate(); }
429
+ return;
430
+ }
431
+ if (data === " ") {
432
+ this.toggleLevelmapRow(f0!.key);
433
+ return;
434
+ }
435
+ return; // edit 模式下其他键不响应
436
+ }
437
+ if (f0!.type === "select" && f0!.options) {
438
+ if (matchesKey(data, "down") || data === "j") {
439
+ this.selectCursor = Math.min(this.selectCursor + 1, f0!.options.length - 1);
440
+ this.invalidate();
441
+ return;
442
+ }
443
+ if (matchesKey(data, "up") || data === "k") {
444
+ this.selectCursor = Math.max(this.selectCursor - 1, 0);
445
+ this.invalidate();
446
+ return;
447
+ }
448
+ if (data === " ") {
449
+ (this.values as any)[f0!.key] = f0!.options[this.selectCursor];
450
+ this.draft = f0!.options[this.selectCursor];
451
+ this.draftIsOriginal = true;
452
+ this.invalidate();
453
+ return;
454
+ }
455
+ return; // edit 模式下其他键不响应
456
+ }
457
+ // multiselect 字段:↑↓ 选 option,Space toggle(在值里加/去)
458
+ if (f0!.type === "multiselect" && f0!.options) {
459
+ const opts = f0!.options;
460
+ if (matchesKey(data, "down") || data === "j") {
461
+ this.multiselectCursor = Math.min(this.multiselectCursor + 1, opts.length - 1);
462
+ this.invalidate();
463
+ return;
464
+ }
465
+ if (matchesKey(data, "up") || data === "k") {
466
+ this.multiselectCursor = Math.max(this.multiselectCursor - 1, 0);
467
+ this.invalidate();
468
+ return;
469
+ }
470
+ if (data === " ") {
471
+ const opt = opts[this.multiselectCursor];
472
+ const cur = (this.values[f0!.key] && Array.isArray(this.values[f0!.key])) ? this.values[f0!.key] as string[] : [];
473
+ const idx = cur.indexOf(opt);
474
+ const next = idx >= 0 ? cur.filter((_, i) => i !== idx) : [...cur, opt];
475
+ (this.values as any)[f0!.key] = next;
476
+ this.draftIsOriginal = false;
477
+ this.invalidate();
478
+ return;
479
+ }
480
+ return;
481
+ }
482
+ }
483
+
484
+ // nav 模式下:↑↓ 切字段(所有字段),j/k 切字段(仅 non-input;text 字段 j/k 是普通字符)
485
+ if (matchesKey(data, "down") || (isNonInput && data === "j")) {
486
+ this.commitDraft();
487
+ this.move(1);
488
+ this.editing = false; // 切到新字段退出 edit
489
+ return;
490
+ }
491
+ if (matchesKey(data, "up") || (isNonInput && data === "k")) {
492
+ this.commitDraft();
493
+ this.move(-1);
494
+ this.editing = false;
495
+ return;
496
+ }
497
+
498
+ // readonly 字段:忽略
499
+ if (f0?.type === "readonly") return;
500
+
501
+ // nav 模式下 Space 在非输入字段:进入 edit 模式(不动值)
502
+ if (data === " " && isNonInput && !this.editing) {
503
+ this.editing = true;
504
+ this.invalidate();
505
+ return;
506
+ }
507
+
508
+ // 文本类输入处理
509
+ if (matchesKey(data, "backspace")) {
510
+ if (this.draft.length > 0) this.draft = this.draft.slice(0, -1);
511
+ this.draftIsOriginal = false;
512
+ this.invalidate();
513
+ return;
514
+ }
515
+ if (data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) < 127) {
516
+ // number 字段:第一个数字替换(避免 100 + "2" = 1002),后续 append
517
+ if (f0?.type === "number" && /^\d$/.test(data) && this.draftIsOriginal) {
518
+ this.draft = data;
519
+ } else {
520
+ this.draft += data;
521
+ }
522
+ this.draftIsOriginal = false;
523
+ this.invalidate();
524
+ return;
525
+ }
526
+ }
527
+
528
+ invalidate(): void { this.cachedWidth = -1; this.cachedLines = []; }
529
+
530
+ private formatValue(f: FormField, v: unknown): string {
531
+ if (f.render) return f.render(v);
532
+ if (v === undefined || v === null) return "";
533
+ if (f.type === "secret") return v ? "••••" + String(v).slice(-2) : "";
534
+ if (f.type === "json") return v === null ? "" : JSON.stringify(v);
535
+ if (f.type === "levelmap") {
536
+ const m = (v && typeof v === "object") ? v as Record<string, string | null> : {};
537
+ const enabled = PI_LEVELS.filter((l) => m[l] != null);
538
+ return enabled.length ? enabled.join(", ") : "(none)";
539
+ }
540
+ if (f.type === "multiselect") {
541
+ const arr = Array.isArray(v) ? v as string[] : [];
542
+ return arr.length ? arr.join(", ") : "(none)";
543
+ }
544
+ return String(v);
545
+ }
546
+
547
+ /** 渲染 select 字段:active 时展开所有 options(cursor 行高亮),非 active 时显示当前值 */
548
+ private renderSelect(f: FormField, isActive: boolean, _labelW: number, width: number): string[] {
549
+ const th = this.theme;
550
+ const out: string[] = [];
551
+ const opts = f.options ?? [];
552
+ const cur = this.values[f.key] as string | undefined;
553
+ const label = (f.label + ":").padEnd(_labelW + 2);
554
+ if (!isActive || (isActive && !this.editing)) {
555
+ const valueStr = cur ? th.fg("text", cur) : th.fg("muted", "(unset)");
556
+ const prefix = isActive ? th.fg("accent", "▸ ") : " ";
557
+ out.push(truncateForRender(prefix + th.bold(label) + valueStr, width));
558
+ return out;
559
+ }
560
+ // active 且 editing:label 行(带 [● edit])+ options 列表
561
+ const editMarker = th.fg("accent", " [● edit]");
562
+ out.push(truncateForRender(th.fg("accent", "▸ ") + th.bold(label) + editMarker, width));
563
+ for (let i = 0; i < opts.length; i++) {
564
+ const isCursor = i === this.selectCursor;
565
+ const isCurrent = opts[i] === cur;
566
+ const arrow = isCursor ? th.fg("accent", "→ ") : " ";
567
+ const box = isCurrent ? th.fg("success", "[√]") : th.fg("dim", "[ ]");
568
+ const optStr = isCurrent ? th.fg("text", opts[i]) : (isCursor ? th.bold(opts[i]) : th.fg("muted", opts[i]));
569
+ out.push(truncateForRender(" " + arrow + box + " " + optStr, width));
570
+ }
571
+ return out;
572
+ }
573
+
574
+ /** 渲染 multiselect 字段:active 且 editing=true 时展开所有 options([√/] 标记已选项 + [● edit]),active 但未 editing 时显示单行,non-active 时显示单行 */
575
+ private renderMultiselect(f: FormField, isActive: boolean, label: string, _labelW: number, width: number): string[] {
576
+ const th = this.theme;
577
+ const out: string[] = [];
578
+ const opts = f.options ?? [];
579
+ const cur = (this.values[f.key] && Array.isArray(this.values[f.key])) ? this.values[f.key] as string[] : [];
580
+ if (!isActive || (isActive && !this.editing)) {
581
+ const valueStr = cur.length ? th.fg("text", cur.join(", ")) : th.fg("muted", "(none)");
582
+ const prefix = isActive ? th.fg("accent", "▸ ") : " ";
583
+ out.push(truncateForRender(prefix + th.bold(label) + valueStr, width));
584
+ return out;
585
+ }
586
+ // active 且 editing:label 行(带 [● edit])+ options 列表
587
+ const editMarker = th.fg("accent", " [● edit]");
588
+ out.push(truncateForRender(th.fg("accent", "▸ ") + th.bold(label) + editMarker, width));
589
+ for (let i = 0; i < opts.length; i++) {
590
+ const opt = opts[i];
591
+ const isCurrent = cur.indexOf(opt) >= 0;
592
+ const isCursor = i === this.multiselectCursor;
593
+ const arrow = isCursor ? th.fg("accent", "→ ") : " ";
594
+ const box = isCurrent ? th.fg("success", "[√]") : th.fg("dim", "[ ]");
595
+ const optStr = isCurrent ? th.fg("text", opt) : (isCursor ? th.bold(opt) : th.fg("muted", opt));
596
+ out.push(truncateForRender(" " + arrow + box + " " + optStr, width));
597
+ }
598
+ return out;
599
+ }
600
+ /** 渲染 levelmap 字段:active 时展开 7 行(首行带 label),非 active 时显示当前 enabled 的 level 列表 */
601
+ private renderLevelmap(f: FormField, isActive: boolean, label: string, _labelW: number, width: number): string[] {
602
+ const th = this.theme;
603
+ const out: string[] = [];
604
+ const cur = ((this.values[f.key] ?? {}) as Record<string, string | null>);
605
+ if (!isActive || (isActive && !this.editing)) {
606
+ // 非 active / active 未 edit:单行显示
607
+ const enabled = PI_LEVELS.filter((l) => cur[l] != null);
608
+ const valueStr = enabled.length ? th.fg("text", enabled.join(", ")) : th.fg("muted", "(none)");
609
+ const prefix = isActive ? th.fg("accent", "▸ ") : " ";
610
+ const line = prefix + th.bold(label) + valueStr;
611
+ out.push(truncateForRender(line, width));
612
+ return out;
613
+ }
614
+ // active 且 editing:label 行(带 [● edit])+ 7 行 level
615
+ const editMarker = th.fg("accent", " [● edit]");
616
+ out.push(truncateForRender(th.fg("accent", "▸ ") + th.bold(label) + editMarker, width));
617
+ for (let i = 0; i < PI_LEVELS.length; i++) {
618
+ const level = PI_LEVELS[i];
619
+ const isOn = cur[level] != null;
620
+ const box = isOn ? th.fg("success", "[√]") : th.fg("dim", "[ ]");
621
+ const isCursor = i === this.levelmapCursor;
622
+ const arrow = isCursor ? th.fg("accent", "→ ") : " ";
623
+ const levelStr = isOn ? th.fg("text", level) : (isCursor ? th.bold(level) : th.fg("dim", level));
624
+ const line = " " + arrow + box + " " + levelStr;
625
+ out.push(truncateForRender(line, width));
626
+ }
627
+ return out;
628
+ }
629
+
630
+ render(width: number): string[] {
631
+ if (this.cachedWidth === width && this.cachedLines.length > 0) return this.cachedLines;
632
+ const th = this.theme;
633
+ const lines: string[] = [];
634
+
635
+ lines.push(th.fg("accent", th.bold(` ${this.title} `)) + th.fg("borderMuted", "─".repeat(Math.max(0, width - this.title.length - 4))));
636
+ if (this.error) lines.push(th.fg("error", ` ⚠ ${this.error}`));
637
+ lines.push("");
638
+
639
+ const labelW = Math.max(...this.fields.map((x) => x.label.length));
640
+ for (let i = 0; i < this.fields.length; i++) {
641
+ const f = this.fields[i];
642
+ const isActive = i === this.cursor;
643
+ // levelmap 字段:active 时展开 7 行;其他字段同原来
644
+ if (f.type === "levelmap") {
645
+ const label = (f.label + ":").padEnd(labelW + 2);
646
+ const linesForLevelmap = this.renderLevelmap(f, isActive, label, labelW, width);
647
+ for (const ln of linesForLevelmap) lines.push(ln);
648
+ continue;
649
+ }
650
+ if (f.type === "select" && f.options) {
651
+ const linesForSelect = this.renderSelect(f, isActive, labelW, width);
652
+ for (const ln of linesForSelect) lines.push(ln);
653
+ continue;
654
+ }
655
+ if (f.type === "multiselect" && f.options) {
656
+ const label = (f.label + ":").padEnd(labelW + 2);
657
+ const linesForMulti = this.renderMultiselect(f, isActive, label, labelW, width);
658
+ for (const ln of linesForMulti) lines.push(ln);
659
+ continue;
660
+ }
661
+ const raw = isActive ? this.draft : this.formatValue(f, this.values[f.key]);
662
+ const isEmpty = !raw;
663
+ const label = (f.label + ":").padEnd(labelW + 2);
664
+ const labelStr = isActive ? th.bold(label) : label;
665
+ let valueStr: string;
666
+ if (f.type === "secret" && !isActive) {
667
+ valueStr = isEmpty ? th.fg("dim", "(empty)") : th.fg("dim", raw);
668
+ } else if (isEmpty) {
669
+ valueStr = th.fg("muted", isActive ? "" : "(empty)");
670
+ } else {
671
+ valueStr = isActive ? raw : th.fg("text", raw);
672
+ }
673
+ const prefix = isActive ? th.fg("accent", "▸ ") : " ";
674
+ const hint = f.hint ? " " + th.fg("muted", f.hint) : "";
675
+ const line = prefix + labelStr + valueStr + hint;
676
+ lines.push(truncateForRender(line, width));
677
+ }
678
+
679
+ lines.push("");
680
+ lines.push(th.fg("borderMuted", "─".repeat(width)));
681
+ const f = this.fields[this.cursor];
682
+ const hints: string[] = ["↑↓ field"];
683
+ if (f?.type === "multiselect") hints.push(this.editing ? "↑↓ option · Space toggle · Enter commit" : "e edit · Space toggle");
684
+ else if (f?.type === "select") hints.push(this.editing ? "↑↓ option · Space pick · Enter commit" : "e edit · Space pick");
685
+ else if (f?.type === "levelmap") hints.push(this.editing ? "↑↓ level · Space toggle · Enter commit" : "e edit · Space toggle");
686
+ else hints.push("type to edit");
687
+ hints.push("Backspace del");
688
+ hints.push("s save");
689
+ hints.push("Esc cancel");
690
+ lines.push(th.fg("dim", " " + hints.join(" · ")));
691
+
692
+ this.cachedWidth = width;
693
+ this.cachedLines = lines;
694
+ return lines;
695
+ }
696
+ }