@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/components.ts CHANGED
@@ -1,866 +1,1193 @@
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
- /** 视口默认行数(不含 header / summary / search / footer / scroll indicator)。8 和 pi 的 /models 一致。
49
- * 在 provider-manager.json#syncViewportSize 可调(5-200)。 */
50
- export const CHECKLIST_DEFAULT_MAX_ROWS = 8;
51
- /** syncViewportSize 越界后由这个补齐 */
52
- export const CHECKLIST_MIN_MAX_ROWS = 5;
53
- export const CHECKLIST_MAX_MAX_ROWS = 200;
54
-
55
- export class ModelChecklist {
56
- private items: ChecklistItem[];
57
- private selected: Set<string>;
58
- private cursor = 0;
59
- private top = 0;
60
- private title: string;
61
- private theme: any;
62
- private maxRows: number;
63
- private onConfirm: (selected: string[]) => void;
64
- private onCancel: () => void;
65
- private cachedWidth = -1;
66
- private cachedLines: string[] = [];
67
- /** search 输入框的当前内容。同步起作用、可被全打印字符 / Backspace 修改。 */
68
- private query = "";
69
-
70
- constructor(opts: {
71
- title: string;
72
- items: ChecklistItem[];
73
- theme: any;
74
- preSelect?: (item: ChecklistItem) => boolean; // 默认全选时筛掉不想要的
75
- maxRows?: number;
76
- onConfirm: (selected: string[]) => void;
77
- onCancel: () => void;
78
- }) {
79
- this.title = opts.title;
80
- this.items = opts.items;
81
- this.theme = opts.theme;
82
- const m = opts.maxRows ?? CHECKLIST_DEFAULT_MAX_ROWS;
83
- this.maxRows = Math.max(CHECKLIST_MIN_MAX_ROWS, Math.min(CHECKLIST_MAX_MAX_ROWS, m));
84
- this.onConfirm = opts.onConfirm;
85
- this.onCancel = opts.onCancel;
86
- this.selected = new Set();
87
- for (const it of opts.items) {
88
- if (it.disabled) continue;
89
- if (opts.preSelect && !opts.preSelect(it)) continue;
90
- this.selected.add(it.id);
91
- }
92
- }
93
-
94
- /** query 作用后的可见项列表。空 query =全量;disabled 始终隐藏。 */
95
- private visibleItems(): ChecklistItem[] {
96
- if (!this.query) return this.items;
97
- const q = this.query.toLowerCase();
98
- return this.items.filter((it) => {
99
- if (it.disabled) return false;
100
- const id = (it.id ?? "").toLowerCase();
101
- const lbl = (it.label ?? "").toLowerCase();
102
- return id.includes(q) || lbl.includes(q);
103
- });
104
- }
105
-
106
- /** top 使 cursor 在视口内。视口高度不含 (top) 钉住额外占的那几行。 */
107
- private adjustTop(viewportH: number): void {
108
- if (viewportH <= 0) return;
109
- if (this.cursor < this.top) this.top = this.cursor;
110
- if (this.cursor >= this.top + viewportH) this.top = this.cursor - viewportH + 1;
111
- if (this.top < 0) this.top = 0;
112
- }
113
-
114
- handleInput(data: string): void {
115
- if (matchesKey(data, "escape") || data === "q") {
116
- this.onCancel();
117
- return;
118
- }
119
- if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
120
- this.onConfirm([...this.selected]);
121
- return;
122
- }
123
- const visible = this.visibleItems();
124
- if (visible.length === 0) {
125
- // 空 list 下只认 search / 退出键
126
- if (matchesKey(data, "backspace")) {
127
- if (this.query.length > 0) {
128
- this.query = this.query.slice(0, -1);
129
- this.cursor = 0;
130
- this.top = 0;
131
- this.invalidate();
132
- }
133
- return;
134
- }
135
- if (data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) < 127) {
136
- this.query += data;
137
- this.cursor = 0;
138
- this.top = 0;
139
- this.invalidate();
140
- }
141
- return;
142
- }
143
- if (data === " ") {
144
- const it = visible[this.cursor];
145
- if (it && !it.disabled) {
146
- if (this.selected.has(it.id)) this.selected.delete(it.id);
147
- else this.selected.add(it.id);
148
- this.invalidate();
149
- }
150
- return;
151
- }
152
- // wrap-around:顶部 ↑ 跳到末项、底部 ↓ 跳回首项(和 pi 的 /models 一致)
153
- if (matchesKey(data, "down") || data === "j") {
154
- this.cursor = (this.cursor + 1) % visible.length;
155
- this.invalidate();
156
- return;
157
- }
158
- if (matchesKey(data, "up") || data === "k") {
159
- this.cursor = (this.cursor - 1 + visible.length) % visible.length;
160
- this.invalidate();
161
- return;
162
- }
163
- if (matchesKey(data, "backspace")) {
164
- // 优先删 search 字;query 空时退化为 no-op(不动 cursor)
165
- if (this.query.length > 0) {
166
- this.query = this.query.slice(0, -1);
167
- // query 变了,重新算 visible 与 cursor
168
- const v2 = this.visibleItems();
169
- if (this.cursor >= v2.length) this.cursor = Math.max(0, v2.length - 1);
170
- this.top = 0;
171
- this.invalidate();
172
- }
173
- return;
174
- }
175
- // 其他可打印字符:进 search(不设 a/g/i 等快捷键了 — search-first 约定)
176
- if (data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) < 127) {
177
- this.query += data;
178
- // query 变了重算
179
- const v2 = this.visibleItems();
180
- if (this.cursor >= v2.length) this.cursor = Math.max(0, v2.length - 1);
181
- this.top = 0;
182
- this.invalidate();
183
- }
184
- }
185
-
186
- invalidate(): void { this.cachedWidth = -1; this.cachedLines = []; }
187
-
188
- render(width: number): string[] {
189
- if (this.cachedWidth === width && this.cachedLines.length > 0) return this.cachedLines;
190
- const th = this.theme;
191
- const lines: string[] = [];
192
-
193
- const total = this.items.length;
194
- const sel = this.selected.size;
195
- const disabled = this.items.filter((it) => it.disabled).length;
196
- const visible = this.visibleItems();
197
- const visCount = visible.length;
198
-
199
- // Header:title + 上划线
200
- const head = th.fg("accent", th.bold(` ${this.title} `)) + th.fg("borderMuted", "─".repeat(Math.max(0, width - this.title.length - 4)));
201
- lines.push(head);
202
- // 顶部 selected / total 状态
203
- const summary = ` ${sel}/${total} selected${disabled ? ` (${disabled} existing, skipped)` : ""} `;
204
- lines.push(th.fg("dim", summary));
205
- // search 输入框(始终显示)
206
- const queryW = Math.max(0, width - 4);
207
- const queryShown = this.query.length > queryW ? this.query.slice(0, queryW) : this.query;
208
- // pi 的 /models 风格:"> " 作为 prompt,"|" 是 cursor。query 末尾加 "▏" 作为 placeholder 提示。
209
- const queryLine = th.fg("accent", " > ") + (this.query ? th.fg("text", queryShown) : th.fg("muted", "▏"));
210
- lines.push(truncateForRender(queryLine, width));
211
- lines.push("");
212
-
213
- // 列表区
214
- // 布局:visible.length > maxRows 时顶部钉住首项 + (top),多占 2 行。
215
- // 这样 mid-list 滚动也能看到 “列表起点是谁” 。
216
- const itemLines: string[] = [];
217
- let moreBelow: string | null = null;
218
- if (visCount === 0) {
219
- itemLines.push(th.fg("dim", this.query
220
- ? ` (no models match "${this.query}")`
221
- : " (no models to choose from)"));
222
- } else {
223
- const needPinOutside = this.cursor >= this.maxRows;
224
- // 钉住首项占 1 行。有效视口 = maxRows - 1(未钉住时 = maxRows)
225
- const effectiveViewport = Math.max(1, this.maxRows - (needPinOutside ? 1 : 0));
226
- this.adjustTop(effectiveViewport);
227
- const viewStart = this.top;
228
- const viewEnd = Math.min(visCount, viewStart + effectiveViewport);
229
-
230
- if (needPinOutside) {
231
- const first = visible[0];
232
- const box = first.disabled ? th.fg("dim", "[skip]")
233
- : this.selected.has(first.id) ? th.fg("success", "[√]")
234
- : th.fg("dim", "[ ]");
235
- itemLines.push(truncateForRender(` ${box} ${first.id} ${th.fg("dim", "(top)")}`, width));
236
- // hidden 计数:剩下未在钉住首项 / viewport 中展示的项
237
- // = total - (1 pinned + viewport) = total - maxRows
238
- const hiddenTotal = visCount - 1 - (viewEnd - viewStart);
239
- if (hiddenTotal > 0) {
240
- itemLines.push(th.fg("dim", ` ⋮ ${hiddenTotal} hidden`));
241
- }
242
- }
243
- for (let i = viewStart; i < viewEnd; i++) {
244
- const it = visible[i];
245
- const isCursor = i === this.cursor;
246
- const arrow = isCursor ? th.fg("accent", "") : " ";
247
- const box = it.disabled ? th.fg("dim", "[skip]")
248
- : this.selected.has(it.id) ? th.fg("success", "[√]")
249
- : th.fg("dim", "[ ]");
250
- const id = isCursor ? th.bold(it.id) : it.id;
251
- const topLabel = (i === 0 && !needPinOutside) ? th.fg("dim", " (top)") : "";
252
- const sub = it.label ? " " + th.fg("muted", it.label) : "";
253
- itemLines.push(truncateForRender(`${arrow}${box} ${id}${topLabel}${sub}`, width));
254
- }
255
- if (viewEnd < visCount) {
256
- const nextId = visible[viewEnd]?.id ?? "";
257
- moreBelow = ` ⋮ ${visCount - viewEnd} more below (${nextId} )`;
258
- }
259
- }
260
-
261
- // items 先
262
- for (const l of itemLines) lines.push(l);
263
- if (moreBelow) lines.push(moreBelow);
264
-
265
- // 位置指示:i / visCount,与 pi 的 /models 一致
266
- // 注意:query 为空时 visCount = total,仍能告知总长度;query 非空时为过滤后位置
267
- if (visCount > 0) {
268
- lines.push(th.fg("muted", ` (${this.cursor + 1}/${visCount})`));
269
- }
270
-
271
- // chrome 置底
272
- lines.push("");
273
- lines.push(th.fg("borderMuted", "─".repeat(width)));
274
- // 底部提示词:search 始终是首选,所以 a/i/g/G 不再是快捷键。
275
- lines.push(th.fg("dim", " type to filter · Space toggle · ↑↓/jk nav (wrap) · Backspace del · Enter apply · Esc cancel"));
276
-
277
- this.cachedWidth = width;
278
- this.cachedLines = lines;
279
- return lines;
280
- }
281
- }
282
-
283
- /** render 用的截断。计算宽度时跳过 ANSI 转义序列 + 已知主题标签,避免裁到一半丢颜色。 */
284
- function truncateForRender(s: string, width: number): string {
285
- if (width <= 0) return "";
286
- const KNOWN_THEME_TAGS = new Set<string>([
287
- "accent", "warning", "dim", "success", "error", "muted", "text",
288
- "borderMuted", "border", "borderAccent",
289
- "background", "primary", "secondary",
290
- "toolTitle", "toolOutput", "toolBg",
291
- "customMessageBg", "userMessageBg", "thinking",
292
- "bold", "italic", "underline", "inverse",
293
- "selection", "comment", "keyword", "string", "number", "function",
294
- "variable", "type", "operator", "punctuation", "property",
295
- ]);
296
- let w = 0;
297
- let out = "";
298
- let i = 0;
299
- while (i < s.length) {
300
- // ANSI 转义序列:\x1b[ ... m(零宽)
301
- if (s[i] === "\x1b" && i + 1 < s.length && s[i + 1] === "[") {
302
- const close = s.indexOf("m", i + 2);
303
- if (close !== -1) {
304
- out += s.slice(i, close + 1);
305
- i = close + 1;
306
- continue;
307
- }
308
- // 其他 CSI 序列:结尾是某个字母
309
- const tail = s.slice(i + 2).search(/[A-Za-z]/);
310
- if (tail !== -1) {
311
- out += s.slice(i, i + 2 + tail + 1);
312
- i = i + 2 + tail + 1;
313
- continue;
314
- }
315
- }
316
- // 旧主题标签 [tag] / [/tag]:仅白名单内的零宽
317
- if (s[i] === "[") {
318
- const close = s.indexOf("]", i + 1);
319
- if (close !== -1) {
320
- const inner = s.slice(i + 1, close);
321
- if (KNOWN_THEME_TAGS.has(inner) || (inner.startsWith("/") && KNOWN_THEME_TAGS.has(inner.slice(1)))) {
322
- out += s.slice(i, close + 1);
323
- i = close + 1;
324
- continue;
325
- }
326
- }
327
- }
328
- const ch = s[i]!;
329
- const cw = isWideChar(ch) ? 2 : 1;
330
- if (w + cw > width) return out + (w + 1 <= width ? "…" : "");
331
- out += ch;
332
- w += cw;
333
- i++;
334
- }
335
- return out;
336
- }
337
-
338
- function isWideChar(ch: string): boolean {
339
- const code = ch.codePointAt(0) ?? 0;
340
- return code > 0x1100 && (
341
- (code >= 0x1100 && code <= 0x115f) ||
342
- (code >= 0x2e80 && code <= 0x9fff) ||
343
- (code >= 0xac00 && code <= 0xd7a3) ||
344
- (code >= 0xff00 && code <= 0xff60) ||
345
- (code >= 0xffe0 && code <= 0xffe6)
346
- );
347
- }
348
-
349
- // ============================================================================
350
- // FormEditor — 通用 TUI 单页表单编辑器
351
- // ============================================================================
352
-
353
- export type FormFieldType = "text" | "secret" | "select" | "number" | "readonly" | "json" | "levelmap" | "multiselect";
354
-
355
- /** pi thinking level 列表。levelmap 字段的勾选/取消就按这个顺序。 */
356
- export const PI_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
357
-
358
- export type FormField = {
359
- key: string;
360
- label: string;
361
- type: FormFieldType;
362
- options?: string[]; // for select
363
- placeholder?: string;
364
- hint?: string; // 显示在 value 后面的小提示
365
- validate?: (value: unknown) => string | null; // 返回错误信息
366
- render?: (value: unknown) => string; // 自定义显示(覆盖默认)
367
- };
368
-
369
- export class FormEditor<T extends Record<string, unknown>> {
370
- private fields: FormField[];
371
- private values: T;
372
- private original: T;
373
- private draft = "";
374
- private draftIsOriginal = true; // draft == currentValue,未编辑过
375
- private cursor = 0;
376
- private levelmapCursor = 0; // levelmap 字段内的 row 位置(0-6)
377
- private selectCursor = 0; // select 字段内的 options 位置
378
- private multiselectCursor = 0; // multiselect 字段内的 options 位置
379
- private editing = false; // 当前非输入字段是否在 edit 模式(Enter 进入、Enter/Space 退出)
380
- private error: string | null = null;
381
- private theme: any;
382
- private title: string;
383
- private onSave: (values: T) => void;
384
- private onCancel: () => void;
385
- private cachedWidth = -1;
386
- private cachedLines: string[] = [];
387
-
388
- constructor(opts: {
389
- title: string;
390
- fields: FormField[];
391
- initial: T;
392
- theme: any;
393
- onSave: (values: T) => void;
394
- onCancel: () => void;
395
- }) {
396
- this.title = opts.title;
397
- this.fields = opts.fields;
398
- this.values = JSON.parse(JSON.stringify(opts.initial)) as T;
399
- this.original = JSON.parse(JSON.stringify(opts.initial)) as T;
400
- this.theme = opts.theme;
401
- this.onSave = opts.onSave;
402
- this.onCancel = opts.onCancel;
403
- this.draft = this.currentValueAsString();
404
- this.draftIsOriginal = true;
405
- }
406
-
407
- private currentValueAsString(): string {
408
- const f = this.fields[this.cursor];
409
- const v = this.values[f.key];
410
- if (v === undefined || v === null) return "";
411
- if (f.type === "secret") return v ? "••••" + String(v).slice(-2) : "";
412
- if (f.type === "json") return v === null ? "" : JSON.stringify(v);
413
- if (f.type === "levelmap") {
414
- const m = (v && typeof v === "object") ? v as Record<string, string | null> : {};
415
- const enabled = PI_LEVELS.filter((l) => m[l] != null);
416
- return enabled.length ? enabled.join(", ") : "(none)";
417
- }
418
- if (f.type === "multiselect") {
419
- const arr = Array.isArray(v) ? v as string[] : [];
420
- return arr.length ? arr.join(", ") : "(none)";
421
- }
422
- return String(v);
423
- }
424
-
425
- private commitDraft(): { ok: boolean } {
426
- const f = this.fields[this.cursor];
427
- if (f.type === "readonly") return { ok: true };
428
- if (f.type === "levelmap") {
429
- // 保证 7 level 都写进 values:勾选的写 level 名,未勾选的写 null(避岀 cycle)
430
- const cur = ((this.values as any)[f.key] ?? {}) as Record<string, string | null>;
431
- const normalized: Record<string, string | null> = {};
432
- for (const lv of PI_LEVELS) {
433
- const v = cur[lv];
434
- normalized[lv] = v === undefined ? null : v;
435
- }
436
- (this.values as any)[f.key] = normalized;
437
- return { ok: true };
438
- }
439
- if (f.type === "multiselect") return { ok: true }; // 直接操作 values,不需 draft
440
- const raw = this.draft;
441
- if (f.type === "number") {
442
- // 严格校验:draft 必须全数字(或空 → 走默认值 0)
443
- if (!/^\d+$/.test(raw)) { this.error = `${f.label}: must be a non-negative integer`; return { ok: false }; }
444
- const n = parseInt(raw, 10);
445
- if (f.validate) {
446
- const err = f.validate(n);
447
- if (err) { this.error = `${f.label}: ${err}`; return { ok: false }; }
448
- }
449
- (this.values as any)[f.key] = n;
450
- } else if (f.type === "json") {
451
- try {
452
- const parsed = raw.trim() ? JSON.parse(raw) : null;
453
- if (f.validate) {
454
- const err = f.validate(parsed);
455
- if (err) { this.error = `${f.label}: ${err}`; return { ok: false }; }
456
- }
457
- (this.values as any)[f.key] = parsed;
458
- } catch (err) {
459
- this.error = `${f.label}: invalid JSON (${err instanceof Error ? err.message : err})`;
460
- return { ok: false };
461
- }
462
- } else if (f.type === "select") {
463
- if (raw && !f.options?.includes(raw)) {
464
- // 宽松容错:true/yes options 中的"yes";false/no → "no";其它字符串报错
465
- if (raw === "true" || raw === "yes") { (this.values as any)[f.key] = (f.options ?? []).find((o) => o === "yes") ?? raw; }
466
- else if (raw === "false" || raw === "no") { (this.values as any)[f.key] = (f.options ?? []).find((o) => o === "no") ?? raw; }
467
- else { this.error = `${f.label}: must be one of ${(f.options ?? []).join(", ")}`; return { ok: false }; }
468
- } else if (raw) { (this.values as any)[f.key] = raw; }
469
- if (f.validate) { const err = f.validate(raw); if (err) { this.error = `${f.label}: ${err}`; return { ok: false }; } }
470
- } else { // text, secret
471
- if (f.validate) { const err = f.validate(raw); if (err) { this.error = `${f.label}: ${err}`; return { ok: false }; } }
472
- // secret: 避免覆写原值。draft 是 masked display ("••••Xn"),如果用户没改(draftIsOriginal=true),
473
- // field / Enter 退出 edit 都会调 commitDraft,不跳这会写回 masked 字符串覆盖真 key。
474
- if (f.type === "secret" && this.draftIsOriginal) {
475
- // no-op,保持 values[f.key] 原值
476
- } else {
477
- (this.values as any)[f.key] = raw;
478
- }
479
- }
480
- this.error = null;
481
- return { ok: true };
482
- }
483
-
484
- private move(delta: number): void {
485
- const result = this.commitDraft();
486
- this.cursor = (this.cursor + delta + this.fields.length) % this.fields.length;
487
- // levelmap/select 字段专用的 sub-cursor:进入/离开都重置
488
- // levelmap 始终 0;select 设为当前 value options 里的 index(找不到则 0)
489
- this.levelmapCursor = 0;
490
- const f = this.fields[this.cursor];
491
- if (f?.type === "select" && f.options) {
492
- const cur = this.values[f.key] as string | undefined;
493
- const idx = cur ? f.options.indexOf(cur) : -1;
494
- this.selectCursor = idx >= 0 ? idx : 0;
495
- } else {
496
- this.selectCursor = 0;
497
- }
498
- this.draft = this.currentValueAsString();
499
- this.draftIsOriginal = true;
500
- // 仅在 commit 成功时清 error;失败时保留让用户看到
501
- if (result.ok) this.error = null;
502
- this.invalidate();
503
- }
504
-
505
- /** levelmap 字段:切换当前 row 的 enable/disable。enable 用 level 名做 value,disable 写 null(避免该 level 进入 cycle)。 */
506
- private toggleLevelmapRow(key: string): void {
507
- const level = PI_LEVELS[this.levelmapCursor];
508
- const cur = ((this.values as any)[key] ?? {}) as Record<string, string | null>;
509
- const next: Record<string, string | null> = { ...cur };
510
- // 保证所有 7 level 都有 key(未勾选的全部以 null 补上,进不了 cycle)
511
- for (const lv of PI_LEVELS) {
512
- if (next[lv] === undefined) next[lv] = null;
513
- }
514
- if (cur[level]) next[level] = null; // 当前 enabled → 写 null(退出 cycle)
515
- else next[level] = level; // 当前 disabled → enable,value = level 名
516
- (this.values as any)[key] = next;
517
- this.draftIsOriginal = false;
518
- this.invalidate();
519
- }
520
-
521
- handleInput(data: string): void {
522
- const f0 = this.fields[this.cursor];
523
- const isNonInput = f0?.type === "select" || f0?.type === "levelmap" || f0?.type === "multiselect";
524
- const isTypeable = f0 && (f0.type === "text" || f0.type === "secret" || f0.type === "number" || f0.type === "json");
525
- const isReadonly = f0?.type === "readonly";
526
-
527
- // 统一模型:所有字段都遵 view / edit 两态。
528
- // view(editing=false,默认):只响应 Enter(进 edit)、↑↓/j/k(切字段)、s(保存)、Esc/q(取消)
529
- // edit(editing=true):可修改(typeable 输字符 / non-typeable 按 Space/↑↓)、Enter(提交+退出)、Esc(退出)、↑↓(提交+切字段)
530
-
531
- // 1. Esc / q → edit 中退出(commit);view 模式取消整个 form
532
- if (matchesKey(data, "escape") || data === "q") {
533
- if (this.editing) {
534
- this.commitDraft();
535
- this.editing = false;
536
- this.invalidate();
537
- return;
538
- }
539
- this.onCancel();
540
- return;
541
- }
542
-
543
- // 2. s → 保存整个 form(仅在 view 模式;edit 模式下 s 是字符 / no-op)
544
- if (data === "s" && !this.editing) {
545
- const result = this.commitDraft();
546
- if (!result.ok) { this.invalidate(); return; }
547
- this.onSave(this.values);
548
- return;
549
- }
550
-
551
- // 3. Enter toggle edit(readonly 不响应)
552
- // view: edit(所有 typeable / non-typeable)
553
- // edit: commit + 退出 edit(不走下一字段,留在原字段;用户用 ↑↓ 切)
554
- if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
555
- if (isReadonly) return;
556
- if (this.editing) {
557
- this.commitDraft();
558
- this.editing = false;
559
- this.invalidate();
560
- return;
561
- }
562
- // 进 edit。secret field:draft = 原值(不是 masked 显示),用户可看/可改真 key;
563
- // commitDraft 会配合 draftIsOriginal 避免覆写未改的原值。
564
- if (f0?.type === "secret") {
565
- const orig = (this.values as any)[f0.key];
566
- this.draft = (orig === undefined || orig === null) ? "" : String(orig);
567
- this.draftIsOriginal = true;
568
- }
569
- this.editing = true;
570
- this.invalidate();
571
- return;
572
- }
573
-
574
- // 4. ↑↓:
575
- // view / edit + typeable:切字段(edit 模式下 commit + 退 edit)
576
- // edit + non-typeable:在 step 7 处理(options 内 nav)
577
- if (matchesKey(data, "down") || matchesKey(data, "up")) {
578
- if (!(this.editing && isNonInput)) {
579
- this.commitDraft();
580
- this.move(matchesKey(data, "up") ? -1 : 1);
581
- this.editing = false;
582
- return;
583
- }
584
- }
585
-
586
- // 5. j/k:
587
- // view:切字段
588
- // edit + typeable:字符(下面 printable 处理)
589
- // edit + non-typeable:options 内 nav(在 step 7 处理)
590
- if ((data === "j" || data === "k") && !this.editing) {
591
- this.move(data === "j" ? 1 : -1);
592
- return;
593
- }
594
-
595
- // 6. Space:view + non-typeable 快捷进 edit(与 Enter 等价)
596
- if (data === " " && isNonInput && !this.editing) {
597
- this.editing = true;
598
- this.invalidate();
599
- return;
600
- }
601
-
602
- // 7. edit + non-typeable:↑↓/j/k 在 options 内移动,Space pick
603
- if (this.editing && isNonInput) {
604
- if (f0!.type === "levelmap") {
605
- if (matchesKey(data, "down") || data === "j") {
606
- if (this.levelmapCursor < 6) { this.levelmapCursor++; this.invalidate(); }
607
- return;
608
- }
609
- if (matchesKey(data, "up") || data === "k") {
610
- if (this.levelmapCursor > 0) { this.levelmapCursor--; this.invalidate(); }
611
- return;
612
- }
613
- if (data === " ") {
614
- this.toggleLevelmapRow(f0!.key);
615
- return;
616
- }
617
- return; // edit 模式下其他键不响应
618
- }
619
- if (f0!.type === "select" && f0!.options) {
620
- if (matchesKey(data, "down") || data === "j") {
621
- this.selectCursor = Math.min(this.selectCursor + 1, f0!.options.length - 1);
622
- this.invalidate();
623
- return;
624
- }
625
- if (matchesKey(data, "up") || data === "k") {
626
- this.selectCursor = Math.max(this.selectCursor - 1, 0);
627
- this.invalidate();
628
- return;
629
- }
630
- if (data === " ") {
631
- (this.values as any)[f0!.key] = f0!.options[this.selectCursor];
632
- this.draft = f0!.options[this.selectCursor];
633
- this.draftIsOriginal = true;
634
- this.invalidate();
635
- return;
636
- }
637
- return; // edit 模式下其他键不响应
638
- }
639
- // multiselect 字段:↑↓/j/k option,Space toggle(在值里加/去)
640
- if (f0!.type === "multiselect" && f0!.options) {
641
- const opts = f0!.options;
642
- if (matchesKey(data, "down") || data === "j") {
643
- this.multiselectCursor = Math.min(this.multiselectCursor + 1, opts.length - 1);
644
- this.invalidate();
645
- return;
646
- }
647
- if (matchesKey(data, "up") || data === "k") {
648
- this.multiselectCursor = Math.max(this.multiselectCursor - 1, 0);
649
- this.invalidate();
650
- return;
651
- }
652
- if (data === " ") {
653
- const opt = opts[this.multiselectCursor];
654
- const cur = (this.values[f0!.key] && Array.isArray(this.values[f0!.key])) ? this.values[f0!.key] as string[] : [];
655
- const idx = cur.indexOf(opt);
656
- const next = idx >= 0 ? cur.filter((_, i) => i !== idx) : [...cur, opt];
657
- (this.values as any)[f0!.key] = next;
658
- this.draftIsOriginal = false;
659
- this.invalidate();
660
- return;
661
- }
662
- return;
663
- }
664
- }
665
-
666
- // 8. readonly:后续输入不响应
667
- if (isReadonly) return;
668
-
669
- // 9. view 模式:不接受任何字符输入(需先 Enter edit)
670
- if (!this.editing) return;
671
-
672
- // 10. Backspace:仅在 edit + typeable 删除 draft
673
- if (matchesKey(data, "backspace")) {
674
- if (!isTypeable) return;
675
- if (this.draft.length > 0) this.draft = this.draft.slice(0, -1);
676
- this.draftIsOriginal = false;
677
- this.invalidate();
678
- return;
679
- }
680
-
681
- // 11. 可打印字符:仅在 edit + typeable 追加 draft
682
- if (data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) < 127) {
683
- if (!isTypeable) return;
684
- // number 字段:第一个数字替换(避免 100 + "2" = 1002),后续 append
685
- if (f0?.type === "number" && /^\d$/.test(data) && this.draftIsOriginal) {
686
- this.draft = data;
687
- } else {
688
- this.draft += data;
689
- }
690
- this.draftIsOriginal = false;
691
- this.invalidate();
692
- return;
693
- }
694
- }
695
-
696
- invalidate(): void { this.cachedWidth = -1; this.cachedLines = []; }
697
-
698
- private formatValue(f: FormField, v: unknown): string {
699
- if (f.render) return f.render(v);
700
- if (v === undefined || v === null) return "";
701
- if (f.type === "secret") return v ? "••••" + String(v).slice(-2) : "";
702
- if (f.type === "json") return v === null ? "" : JSON.stringify(v);
703
- if (f.type === "levelmap") {
704
- const m = (v && typeof v === "object") ? v as Record<string, string | null> : {};
705
- const enabled = PI_LEVELS.filter((l) => m[l] != null);
706
- return enabled.length ? enabled.join(", ") : "(none)";
707
- }
708
- if (f.type === "multiselect") {
709
- const arr = Array.isArray(v) ? v as string[] : [];
710
- return arr.length ? arr.join(", ") : "(none)";
711
- }
712
- return String(v);
713
- }
714
-
715
- /** 渲染 select 字段:active 时展开所有 options(cursor 行高亮),非 active 时显示当前值 */
716
- private renderSelect(f: FormField, isActive: boolean, _labelW: number, width: number): string[] {
717
- const th = this.theme;
718
- const out: string[] = [];
719
- const opts = f.options ?? [];
720
- const cur = this.values[f.key] as string | undefined;
721
- const label = (f.label + ":").padEnd(_labelW + 2);
722
- if (!isActive || (isActive && !this.editing)) {
723
- const valueStr = cur ? th.fg("text", cur) : th.fg("muted", "(unset)");
724
- const prefix = isActive ? th.fg("accent", "▸ ") : " ";
725
- out.push(truncateForRender(prefix + th.bold(label) + valueStr, width));
726
- return out;
727
- }
728
- // active 且 editing:label 行(带 [● edit])+ options 列表
729
- const editMarker = th.fg("accent", " [● edit]");
730
- out.push(truncateForRender(th.fg("accent", "▸ ") + th.bold(label) + editMarker, width));
731
- for (let i = 0; i < opts.length; i++) {
732
- const isCursor = i === this.selectCursor;
733
- const isCurrent = opts[i] === cur;
734
- const arrow = isCursor ? th.fg("accent", "→ ") : " ";
735
- const box = isCurrent ? th.fg("success", "[√]") : th.fg("dim", "[ ]");
736
- const optStr = isCurrent ? th.fg("text", opts[i]) : (isCursor ? th.bold(opts[i]) : th.fg("muted", opts[i]));
737
- out.push(truncateForRender(" " + arrow + box + " " + optStr, width));
738
- }
739
- return out;
740
- }
741
-
742
- /** 渲染 multiselect 字段:active editing=true 时展开所有 options([√/] 标记已选项 + [● edit]),active 但未 editing 时显示单行,non-active 时显示单行 */
743
- private renderMultiselect(f: FormField, isActive: boolean, label: string, _labelW: number, width: number): string[] {
744
- const th = this.theme;
745
- const out: string[] = [];
746
- const opts = f.options ?? [];
747
- const cur = (this.values[f.key] && Array.isArray(this.values[f.key])) ? this.values[f.key] as string[] : [];
748
- if (!isActive || (isActive && !this.editing)) {
749
- const valueStr = cur.length ? th.fg("text", cur.join(", ")) : th.fg("muted", "(none)");
750
- const prefix = isActive ? th.fg("accent", "▸ ") : " ";
751
- out.push(truncateForRender(prefix + th.bold(label) + valueStr, width));
752
- return out;
753
- }
754
- // active 且 editing:label 行(带 [● edit])+ options 列表
755
- const editMarker = th.fg("accent", " [● edit]");
756
- out.push(truncateForRender(th.fg("accent", "▸ ") + th.bold(label) + editMarker, width));
757
- for (let i = 0; i < opts.length; i++) {
758
- const opt = opts[i];
759
- const isCurrent = cur.indexOf(opt) >= 0;
760
- const isCursor = i === this.multiselectCursor;
761
- const arrow = isCursor ? th.fg("accent", "→ ") : " ";
762
- const box = isCurrent ? th.fg("success", "[√]") : th.fg("dim", "[ ]");
763
- const optStr = isCurrent ? th.fg("text", opt) : (isCursor ? th.bold(opt) : th.fg("muted", opt));
764
- out.push(truncateForRender(" " + arrow + box + " " + optStr, width));
765
- }
766
- return out;
767
- }
768
- /** 渲染 levelmap 字段:active 时展开 7 行(首行带 label),非 active 时显示当前 enabled 的 level 列表 */
769
- private renderLevelmap(f: FormField, isActive: boolean, label: string, _labelW: number, width: number): string[] {
770
- const th = this.theme;
771
- const out: string[] = [];
772
- const cur = ((this.values[f.key] ?? {}) as Record<string, string | null>);
773
- if (!isActive || (isActive && !this.editing)) {
774
- // 非 active / active 未 edit:单行显示
775
- const enabled = PI_LEVELS.filter((l) => cur[l] != null);
776
- const valueStr = enabled.length ? th.fg("text", enabled.join(", ")) : th.fg("muted", "(none)");
777
- const prefix = isActive ? th.fg("accent", "▸ ") : " ";
778
- const line = prefix + th.bold(label) + valueStr;
779
- out.push(truncateForRender(line, width));
780
- return out;
781
- }
782
- // active editing:label 行(带 [● edit])+ 7 level
783
- const editMarker = th.fg("accent", " [● edit]");
784
- out.push(truncateForRender(th.fg("accent", "▸ ") + th.bold(label) + editMarker, width));
785
- for (let i = 0; i < PI_LEVELS.length; i++) {
786
- const level = PI_LEVELS[i];
787
- const isOn = cur[level] != null;
788
- const box = isOn ? th.fg("success", "[√]") : th.fg("dim", "[ ]");
789
- const isCursor = i === this.levelmapCursor;
790
- const arrow = isCursor ? th.fg("accent", "→ ") : " ";
791
- const levelStr = isOn ? th.fg("text", level) : (isCursor ? th.bold(level) : th.fg("dim", level));
792
- const line = " " + arrow + box + " " + levelStr;
793
- out.push(truncateForRender(line, width));
794
- }
795
- return out;
796
- }
797
-
798
- render(width: number): string[] {
799
- if (this.cachedWidth === width && this.cachedLines.length > 0) return this.cachedLines;
800
- const th = this.theme;
801
- const lines: string[] = [];
802
-
803
- lines.push(th.fg("accent", th.bold(` ${this.title} `)) + th.fg("borderMuted", "─".repeat(Math.max(0, width - this.title.length - 4))));
804
- if (this.error) lines.push(th.fg("error", ` ⚠ ${this.error}`));
805
- lines.push("");
806
-
807
- const labelW = Math.max(...this.fields.map((x) => x.label.length));
808
- for (let i = 0; i < this.fields.length; i++) {
809
- const f = this.fields[i];
810
- const isActive = i === this.cursor;
811
- // levelmap 字段:active 时展开 7 行;其他字段同原来
812
- if (f.type === "levelmap") {
813
- const label = (f.label + ":").padEnd(labelW + 2);
814
- const linesForLevelmap = this.renderLevelmap(f, isActive, label, labelW, width);
815
- for (const ln of linesForLevelmap) lines.push(ln);
816
- continue;
817
- }
818
- if (f.type === "select" && f.options) {
819
- const linesForSelect = this.renderSelect(f, isActive, labelW, width);
820
- for (const ln of linesForSelect) lines.push(ln);
821
- continue;
822
- }
823
- if (f.type === "multiselect" && f.options) {
824
- const label = (f.label + ":").padEnd(labelW + 2);
825
- const linesForMulti = this.renderMultiselect(f, isActive, label, labelW, width);
826
- for (const ln of linesForMulti) lines.push(ln);
827
- continue;
828
- }
829
- const raw = isActive ? this.draft : this.formatValue(f, this.values[f.key]);
830
- const isEmpty = !raw;
831
- const label = (f.label + ":").padEnd(labelW + 2);
832
- const labelStr = isActive ? th.bold(label) : label;
833
- let valueStr: string;
834
- if (f.type === "secret" && !isActive) {
835
- valueStr = isEmpty ? th.fg("dim", "(empty)") : th.fg("dim", raw);
836
- } else if (isEmpty) {
837
- valueStr = th.fg("muted", isActive ? "" : "(empty)");
838
- } else {
839
- valueStr = isActive ? raw : th.fg("text", raw);
840
- }
841
- const prefix = isActive ? th.fg("accent", "▸ ") : " ";
842
- const editMarker = isActive && this.editing ? th.fg("accent", " [● edit]") : "";
843
- const hint = f.hint ? " " + th.fg("muted", f.hint) : "";
844
- const line = prefix + labelStr + valueStr + editMarker + hint;
845
- lines.push(truncateForRender(line, width));
846
- }
847
-
848
- lines.push("");
849
- lines.push(th.fg("borderMuted", "─".repeat(width)));
850
- const f = this.fields[this.cursor];
851
- const hints: string[] = ["↑↓ field"];
852
- if (f?.type === "multiselect") hints.push(this.editing ? "↑↓ option · Space toggle · Enter commit" : "Enter edit · Space toggle");
853
- else if (f?.type === "select") hints.push(this.editing ? "↑↓ option · Space pick · Enter commit" : "Enter edit · Space pick");
854
- else if (f?.type === "levelmap") hints.push(this.editing ? "↑↓ level · Space toggle · Enter commit" : "Enter edit · Space toggle");
855
- else if (f?.type === "readonly") hints.push("readonly");
856
- else hints.push(this.editing ? "type to edit" : "Enter edit · type");
857
- hints.push(this.editing && f && (f.type === "text" || f.type === "secret" || f.type === "number" || f.type === "json") ? "Backspace del" : "Backspace");
858
- hints.push(this.editing ? "Enter commit" : "s save");
859
- hints.push("Esc cancel");
860
- lines.push(th.fg("dim", " " + hints.join(" · ")));
861
-
862
- this.cachedWidth = width;
863
- this.cachedLines = lines;
864
- return lines;
865
- }
866
- }
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
+ // Bracketed-paste 助手
40
+ // =========================================================================
41
+ //
42
+ // pi 框架的 Ctrl+V / Alt+V / 右键粘贴都走 readClipboardText(),拿到的文本
43
+ // 用 ESC[200~ ... ESC[201~ 包后调 formEditor.handleInput。我们之前的
44
+ // handleInput 只认 “单字节 + charCode 32-126”,遇到这个多字节序列会
45
+ // 静默丢,与 “pwsh 下能选中文/多行 key 但 ctrl+v 无反应” 症状一致。
46
+ //
47
+ // Feed 每次输入 chunk;返回 true = 本 chunk 被粘贴状态完全消费(调用方退出)。
48
+ // 返回 false = 不是粘贴;调用方走常规路径。
49
+ // 跨多次 feed() 累积;遇到结束标记时调一次 onPaste(干净文本) 并清状态。
50
+ // 当本 chunk 结束后还有剩余字符(同一 chunk 里在结束标记后还有正常输入),
51
+ // 存到 `lastRest`,调用方可用 `consumeRest()` 拿到并重调。
52
+ export class PasteBuffer {
53
+ private isInPaste = false;
54
+ private buf = "";
55
+ private lastRest = "";
56
+ /** Called once when a complete bracketed paste arrives. */
57
+ onPaste: (cleanText: string) => void = () => {};
58
+
59
+ feed(data: string): boolean {
60
+ // 粘贴中:累积到 buf 等结束标记
61
+ if (this.isInPaste) {
62
+ this.buf += data;
63
+ const end = this.buf.indexOf("\x1b[201~");
64
+ if (end >= 0) {
65
+ const text = this.buf.substring(0, end);
66
+ const rest = this.buf.substring(end + 6);
67
+ this.isInPaste = false;
68
+ this.buf = "";
69
+ this.onPaste(sanitizePasteText(text));
70
+ if (rest) this.lastRest = rest;
71
+ }
72
+ return true;
73
+ }
74
+ // 未粘贴:检测开始标记
75
+ if (data.includes("\x1b[200~")) {
76
+ this.isInPaste = true;
77
+ this.buf = "";
78
+ const after = data.replace("\x1b[200~", "");
79
+ // 同一 chunk 里也可能有结束标记
80
+ const end = after.indexOf("\x1b[201~");
81
+ if (end >= 0) {
82
+ const text = after.substring(0, end);
83
+ const rest = after.substring(end + 6);
84
+ this.isInPaste = false;
85
+ this.onPaste(sanitizePasteText(text));
86
+ if (rest) this.lastRest = rest;
87
+ return true;
88
+ }
89
+ this.buf = after;
90
+ return true;
91
+ }
92
+ return false; // 非粘贴,调用方走常规路径
93
+ }
94
+
95
+ /** 取走上次 paste 之后的剩余字符(如果有)。调用后清空。 */
96
+ consumeRest(): string {
97
+ const r = this.lastRest;
98
+ this.lastRest = "";
99
+ return r;
100
+ }
101
+ }
102
+
103
+ /** 清理粘贴文本:去 \r\n / \r / \n;\t → 4 空格。
104
+ * 对齐 pi-tui Input.handlePaste 的行为。单行字段不需要换行。 */
105
+ function sanitizePasteText(s: string): string {
106
+ return s.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "").replace(/\t/g, " ");
107
+ }
108
+
109
+ // ============================================================================
110
+ // ModelChecklist 多选 checklist(sync 用)
111
+ // ============================================================================
112
+
113
+ export type ChecklistItem = {
114
+ id: string;
115
+ label?: string; // 副标题/备注
116
+ disabled?: boolean; // true = 显示但不让选
117
+ };
118
+
119
+ /** 视口默认行数(不含 header / summary / search / footer / scroll indicator)。8 和 pi 的 /models 一致。
120
+ * 在 provider-manager.json#syncViewportSize 可调(5-200)。 */
121
+ export const CHECKLIST_DEFAULT_MAX_ROWS = 8;
122
+ /** syncViewportSize 越界后由这个补齐 */
123
+ export const CHECKLIST_MIN_MAX_ROWS = 5;
124
+ export const CHECKLIST_MAX_MAX_ROWS = 200;
125
+
126
+ export class ModelChecklist {
127
+ private items: ChecklistItem[];
128
+ private selected: Set<string>;
129
+ private cursor = 0;
130
+ private top = 0;
131
+ private title: string;
132
+ private theme: any;
133
+ private maxRows: number;
134
+ private onConfirm: (selected: string[]) => void;
135
+ private onCancel: () => void;
136
+ private cachedWidth = -1;
137
+ private cachedLines: string[] = [];
138
+ /** 括包粘贴缓冲:pi 框架的 Ctrl+V / Alt+V / 右键粘贴会用 ESC[200~..ESC[201~ 包后 call handleInput。 */
139
+ private pasteBuf = new PasteBuffer();
140
+ /** search 输入框的当前内容。同步起作用、可被全打印字符 / Backspace 修改。 */
141
+ private query = "";
142
+
143
+ constructor(opts: {
144
+ title: string;
145
+ items: ChecklistItem[];
146
+ theme: any;
147
+ preSelect?: (item: ChecklistItem) => boolean; // 默认全选时筛掉不想要的
148
+ maxRows?: number;
149
+ onConfirm: (selected: string[]) => void;
150
+ onCancel: () => void;
151
+ }) {
152
+ this.title = opts.title;
153
+ this.items = opts.items;
154
+ this.theme = opts.theme;
155
+ const m = opts.maxRows ?? CHECKLIST_DEFAULT_MAX_ROWS;
156
+ this.maxRows = Math.max(CHECKLIST_MIN_MAX_ROWS, Math.min(CHECKLIST_MAX_MAX_ROWS, m));
157
+ this.onConfirm = opts.onConfirm;
158
+ this.onCancel = opts.onCancel;
159
+ this.selected = new Set();
160
+ for (const it of opts.items) {
161
+ if (it.disabled) continue;
162
+ if (opts.preSelect && !opts.preSelect(it)) continue;
163
+ this.selected.add(it.id);
164
+ }
165
+ // 粘贴多字符进 search 输入框(如粘贴 apiKey / model id 过滤)
166
+ this.pasteBuf.onPaste = (text) => {
167
+ if (!text) return;
168
+ this.query += text;
169
+ const v = this.visibleItems();
170
+ if (this.cursor >= v.length) this.cursor = Math.max(0, v.length - 1);
171
+ this.top = 0;
172
+ this.invalidate();
173
+ };
174
+ }
175
+
176
+ /** query 作用后的可见项列表。空 query =全量;disabled 始终隐藏。 */
177
+ private visibleItems(): ChecklistItem[] {
178
+ if (!this.query) return this.items;
179
+ const q = this.query.toLowerCase();
180
+ return this.items.filter((it) => {
181
+ if (it.disabled) return false;
182
+ const id = (it.id ?? "").toLowerCase();
183
+ const lbl = (it.label ?? "").toLowerCase();
184
+ return id.includes(q) || lbl.includes(q);
185
+ });
186
+ }
187
+
188
+ /** 调 top 使 cursor 在视口内。视口高度不含 (top) 钉住额外占的那几行。 */
189
+ private adjustTop(viewportH: number): void {
190
+ if (viewportH <= 0) return;
191
+ if (this.cursor < this.top) this.top = this.cursor;
192
+ if (this.cursor >= this.top + viewportH) this.top = this.cursor - viewportH + 1;
193
+ if (this.top < 0) this.top = 0;
194
+ }
195
+
196
+ handleInput(data: string): void {
197
+ // 括包粘贴:Ctrl+V / Alt+V / 右键粘走 PasteBuffer
198
+ if (this.pasteBuf.feed(data)) {
199
+ const rest = this.pasteBuf.consumeRest();
200
+ if (rest) this.processRest(rest);
201
+ return;
202
+ }
203
+ if (matchesKey(data, "escape") || data === "q") {
204
+ this.onCancel();
205
+ return;
206
+ }
207
+ if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
208
+ this.onConfirm([...this.selected]);
209
+ return;
210
+ }
211
+ const visible = this.visibleItems();
212
+ if (visible.length === 0) {
213
+ // 空 list 下只认 search / 退出键
214
+ if (matchesKey(data, "backspace")) {
215
+ if (this.query.length > 0) {
216
+ this.query = this.query.slice(0, -1);
217
+ this.cursor = 0;
218
+ this.top = 0;
219
+ this.invalidate();
220
+ }
221
+ return;
222
+ }
223
+ if (data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) < 127) {
224
+ this.query += data;
225
+ this.cursor = 0;
226
+ this.top = 0;
227
+ this.invalidate();
228
+ }
229
+ return;
230
+ }
231
+ if (data === " ") {
232
+ const it = visible[this.cursor];
233
+ if (it && !it.disabled) {
234
+ if (this.selected.has(it.id)) this.selected.delete(it.id);
235
+ else this.selected.add(it.id);
236
+ this.invalidate();
237
+ }
238
+ return;
239
+ }
240
+ // wrap-around:顶部 跳到末项、底部 ↓ 跳回首项(和 pi 的 /models 一致)
241
+ if (matchesKey(data, "down") || data === "j") {
242
+ this.cursor = (this.cursor + 1) % visible.length;
243
+ this.invalidate();
244
+ return;
245
+ }
246
+ if (matchesKey(data, "up") || data === "k") {
247
+ this.cursor = (this.cursor - 1 + visible.length) % visible.length;
248
+ this.invalidate();
249
+ return;
250
+ }
251
+ if (matchesKey(data, "backspace")) {
252
+ // 优先删 search 字;query 空时退化为 no-op(不动 cursor)
253
+ if (this.query.length > 0) {
254
+ this.query = this.query.slice(0, -1);
255
+ // query 变了,重新算 visible 与 cursor
256
+ const v2 = this.visibleItems();
257
+ if (this.cursor >= v2.length) this.cursor = Math.max(0, v2.length - 1);
258
+ this.top = 0;
259
+ this.invalidate();
260
+ }
261
+ return;
262
+ }
263
+ // 其他可打印字符:进 search(不设 a/g/i 等快捷键了 — search-first 约定)
264
+ if (data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) < 127) {
265
+ this.query += data;
266
+ // query 变了重算
267
+ const v2 = this.visibleItems();
268
+ if (this.cursor >= v2.length) this.cursor = Math.max(0, v2.length - 1);
269
+ this.top = 0;
270
+ this.invalidate();
271
+ }
272
+ }
273
+
274
+ /** 处理括包粘贴后剩余的字符串:按字符逐个调 handleInput(不走 PasteBuffer 路径)。 */
275
+ private processRest(rest: string): void {
276
+ for (const ch of rest) {
277
+ // 直接走常规路径。上面 handleInput 的 pasteBuf.feed 在这个深度返回 false(同一实例状态不嵌套)
278
+ this.dispatchChar(ch);
279
+ }
280
+ }
281
+
282
+ /** 单个字符的常规处理(从 handleInput 拆出,给 processRest 复用)。
283
+ * 只处理 “单可打印字符” / Space / Backspace;其他键(多字节转义序列、Enter 等)不响应。 */
284
+ private dispatchChar(ch: string): void {
285
+ if (matchesKey(ch, "backspace")) {
286
+ if (this.query.length > 0) {
287
+ this.query = this.query.slice(0, -1);
288
+ const v2 = this.visibleItems();
289
+ if (this.cursor >= v2.length) this.cursor = Math.max(0, v2.length - 1);
290
+ this.top = 0;
291
+ this.invalidate();
292
+ }
293
+ return;
294
+ }
295
+ if (ch === " ") {
296
+ const visible = this.visibleItems();
297
+ const it = visible[this.cursor];
298
+ if (it && !it.disabled) {
299
+ if (this.selected.has(it.id)) this.selected.delete(it.id);
300
+ else this.selected.add(it.id);
301
+ this.invalidate();
302
+ }
303
+ return;
304
+ }
305
+ if (ch.length === 1 && ch.charCodeAt(0) >= 32 && ch.charCodeAt(0) < 127) {
306
+ this.query += ch;
307
+ const v2 = this.visibleItems();
308
+ if (this.cursor >= v2.length) this.cursor = Math.max(0, v2.length - 1);
309
+ this.top = 0;
310
+ this.invalidate();
311
+ }
312
+ }
313
+
314
+ invalidate(): void { this.cachedWidth = -1; this.cachedLines = []; }
315
+
316
+ render(width: number): string[] {
317
+ if (this.cachedWidth === width && this.cachedLines.length > 0) return this.cachedLines;
318
+ const th = this.theme;
319
+ const body: string[] = [];
320
+
321
+ // box 外边框占 4 列(│×2 + 内边距×2),内容按 width-4 布局避免套框超宽
322
+ const cw = Math.max(20, width - 4);
323
+
324
+ const total = this.items.length;
325
+ const sel = this.selected.size;
326
+ const disabled = this.items.filter((it) => it.disabled).length;
327
+ const visible = this.visibleItems();
328
+ const visCount = visible.length;
329
+
330
+ // Header:title + 上划线
331
+ const head = th.fg("accent", th.bold(` ${this.title} `)) + th.fg("borderMuted", "─".repeat(Math.max(0, cw - this.title.length - 4)));
332
+ body.push(head);
333
+ // 顶部 selected / total 状态
334
+ const summary = ` ${sel}/${total} selected${disabled ? ` (${disabled} existing, skipped)` : ""} `;
335
+ body.push(th.fg("dim", summary));
336
+ // search 输入框(始终显示)
337
+ const queryW = Math.max(0, cw - 4);
338
+ const queryShown = this.query.length > queryW ? this.query.slice(0, queryW) : this.query;
339
+ // pi /models 风格:"> " 作为 prompt,"|" 是 cursor。query 末尾加 "▏" 作为 placeholder 提示。
340
+ const queryLine = th.fg("accent", " > ") + (this.query ? th.fg("text", queryShown) : th.fg("muted", "▏"));
341
+ body.push(truncateForRender(queryLine, cw));
342
+ body.push("");
343
+
344
+ // 列表区
345
+ // 布局:visible.length > maxRows 时顶部钉住首项 + (top),多占 2 行。
346
+ // 这样 mid-list 滚动也能看到 “列表起点是谁” 。
347
+ const itemLines: string[] = [];
348
+ let moreBelow: string | null = null;
349
+ if (visCount === 0) {
350
+ itemLines.push(th.fg("dim", this.query
351
+ ? ` (no models match "${this.query}")`
352
+ : " (no models to choose from)"));
353
+ } else {
354
+ const needPinOutside = this.cursor >= this.maxRows;
355
+ // 钉住首项占 1 行。有效视口 = maxRows - 1(未钉住时 = maxRows)
356
+ const effectiveViewport = Math.max(1, this.maxRows - (needPinOutside ? 1 : 0));
357
+ this.adjustTop(effectiveViewport);
358
+ const viewStart = this.top;
359
+ const viewEnd = Math.min(visCount, viewStart + effectiveViewport);
360
+
361
+ if (needPinOutside) {
362
+ const first = visible[0];
363
+ const box = first.disabled ? th.fg("dim", "[skip]")
364
+ : this.selected.has(first.id) ? th.fg("success", "[√]")
365
+ : th.fg("dim", "[ ]");
366
+ itemLines.push(truncateForRender(` ${box} ${first.id} ${th.fg("dim", "(top)")}`, cw));
367
+ // hidden 计数:剩下未在钉住首项 / viewport 中展示的项
368
+ // = total - (1 pinned + viewport) = total - maxRows
369
+ const hiddenTotal = visCount - 1 - (viewEnd - viewStart);
370
+ if (hiddenTotal > 0) {
371
+ itemLines.push(th.fg("dim", ` ⋮ ${hiddenTotal} hidden`));
372
+ }
373
+ }
374
+ for (let i = viewStart; i < viewEnd; i++) {
375
+ const it = visible[i];
376
+ const isCursor = i === this.cursor;
377
+ const arrow = isCursor ? th.fg("accent", "▶ ") : " ";
378
+ const box = it.disabled ? th.fg("dim", "[skip]")
379
+ : this.selected.has(it.id) ? th.fg("success", "[√]")
380
+ : th.fg("dim", "[ ]");
381
+ const id = isCursor ? th.bold(it.id) : it.id;
382
+ const topLabel = (i === 0 && !needPinOutside) ? th.fg("dim", " (top)") : "";
383
+ const sub = it.label ? " " + th.fg("muted", it.label) : "";
384
+ itemLines.push(truncateForRender(`${arrow}${box} ${id}${topLabel}${sub}`, cw));
385
+ }
386
+ if (viewEnd < visCount) {
387
+ const nextId = visible[viewEnd]?.id ?? "";
388
+ moreBelow = ` ⋮ ${visCount - viewEnd} more below (${nextId} …)`;
389
+ }
390
+ }
391
+
392
+ // items 先
393
+ for (const l of itemLines) body.push(l);
394
+ if (moreBelow) body.push(moreBelow);
395
+
396
+ // 位置指示:i / visCount,与 pi 的 /models 一致
397
+ // 注意:query 为空时 visCount = total,仍能告知总长度;query 非空时为过滤后位置
398
+ if (visCount > 0) {
399
+ body.push(th.fg("muted", ` (${this.cursor + 1}/${visCount})`));
400
+ }
401
+
402
+ // chrome 置底
403
+ body.push("");
404
+ body.push(th.fg("borderMuted", "─".repeat(cw)));
405
+ // 底部提示词:search 始终是首选,所以 a/i/g/G 不再是快捷键。
406
+ // 底部提示词:按 cw 软换行(box 会截断超宽行,避免尾部键位丢失)
407
+ const hintParts = ["type to filter", "Space toggle", "↑↓/jk nav (wrap)", "Backspace del", "Enter apply", "Esc cancel"];
408
+ let hintLine = "";
409
+ for (const p of hintParts) {
410
+ const cand = hintLine ? hintLine + " · " + p : " " + p;
411
+ if (cand.length > cw && hintLine) {
412
+ body.push(th.fg("dim", hintLine));
413
+ hintLine = " " + p;
414
+ } else {
415
+ hintLine = cand;
416
+ }
417
+ }
418
+ body.push(th.fg("dim", hintLine));
419
+
420
+ // 外边框:浮窗加 box,让 tui 里的 overlay 看起来不糊
421
+ const lines = box(th, width, this.title, body);
422
+ this.cachedWidth = width;
423
+ this.cachedLines = lines;
424
+ return lines;
425
+ }
426
+ }
427
+
428
+ /** render 用的截断。计算宽度时跳过 ANSI 转义序列 + 已知主题标签,避免裁到一半丢颜色。 */
429
+ export function truncateForRender(s: string, width: number): string {
430
+ if (width <= 0) return "";
431
+ const KNOWN_THEME_TAGS = new Set<string>([
432
+ "accent", "warning", "dim", "success", "error", "muted", "text",
433
+ "borderMuted", "border", "borderAccent",
434
+ "background", "primary", "secondary",
435
+ "toolTitle", "toolOutput", "toolBg",
436
+ "customMessageBg", "userMessageBg", "thinking",
437
+ "bold", "italic", "underline", "inverse",
438
+ "selection", "comment", "keyword", "string", "number", "function",
439
+ "variable", "type", "operator", "punctuation", "property",
440
+ ]);
441
+ let w = 0;
442
+ let out = "";
443
+ let i = 0;
444
+ while (i < s.length) {
445
+ // ANSI 转义序列:\x1b[ ... m(零宽)
446
+ if (s[i] === "\x1b" && i + 1 < s.length && s[i + 1] === "[") {
447
+ const close = s.indexOf("m", i + 2);
448
+ if (close !== -1) {
449
+ out += s.slice(i, close + 1);
450
+ i = close + 1;
451
+ continue;
452
+ }
453
+ // 其他 CSI 序列:结尾是某个字母
454
+ const tail = s.slice(i + 2).search(/[A-Za-z]/);
455
+ if (tail !== -1) {
456
+ out += s.slice(i, i + 2 + tail + 1);
457
+ i = i + 2 + tail + 1;
458
+ continue;
459
+ }
460
+ }
461
+ // 旧主题标签 [tag] / [/tag]:仅白名单内的零宽
462
+ if (s[i] === "[") {
463
+ const close = s.indexOf("]", i + 1);
464
+ if (close !== -1) {
465
+ const inner = s.slice(i + 1, close);
466
+ if (KNOWN_THEME_TAGS.has(inner) || (inner.startsWith("/") && KNOWN_THEME_TAGS.has(inner.slice(1)))) {
467
+ out += s.slice(i, close + 1);
468
+ i = close + 1;
469
+ continue;
470
+ }
471
+ }
472
+ }
473
+ const ch = s[i]!;
474
+ const cw = isWideChar(ch) ? 2 : 1;
475
+ if (w + cw > width) return out + (w + 1 <= width ? "…" : "");
476
+ out += ch;
477
+ w += cw;
478
+ i++;
479
+ }
480
+ return out;
481
+ }
482
+
483
+ function isWideChar(ch: string): boolean {
484
+ const code = ch.codePointAt(0) ?? 0;
485
+ return code > 0x1100 && (
486
+ (code >= 0x1100 && code <= 0x115f) ||
487
+ (code >= 0x2e80 && code <= 0x9fff) ||
488
+ (code >= 0xac00 && code <= 0xd7a3) ||
489
+ (code >= 0xff00 && code <= 0xff60) ||
490
+ (code >= 0xffe0 && code <= 0xffe6)
491
+ );
492
+ }
493
+
494
+ // ============================================================================
495
+ // Border box — 给浮窗式组件加外边框
496
+ //
497
+ // TUI 里的 overlay 默认不带边框,混在对话流里会糊。包一个矩形让用户一眼看出是浮窗。
498
+ // 调用方式:
499
+ // const lines = body(title, contentLines, theme, width);
500
+ // - width 是外框的可见宽度(包括两侧 `│` + 内部 1 格 padding)
501
+ // - title 可空,为空时顶部是一行纯 `─`
502
+ // - 返回后的 lines 总高 = contentLines.length + 2(顶 + 底边框)
503
+ // 视觉:
504
+ // ┌─ title ──────────────────────────────────────────────────┐
505
+ // │ content line 1 │
506
+ // │ content line 2 │
507
+ // └──────────────────────────────────────────────────────────┘
508
+ // ============================================================================
509
+
510
+ const KNOWN_THEME_TAGS_FOR_BOX = new Set<string>([
511
+ "accent", "warning", "dim", "success", "error", "muted", "text",
512
+ "borderMuted", "border", "borderAccent",
513
+ "background", "primary", "secondary",
514
+ "toolTitle", "toolOutput", "toolBg",
515
+ "customMessageBg", "userMessageBg", "thinking",
516
+ "bold", "italic", "underline", "inverse",
517
+ "selection", "comment", "keyword", "string", "number", "function",
518
+ "variable", "type", "operator", "punctuation", "property",
519
+ ]);
520
+
521
+ /** 算一个字符串的可见宽度(跳过 ANSI 转义 + 已知主题标签) */
522
+ function visibleWidthForBox(s: string): number {
523
+ let w = 0;
524
+ let i = 0;
525
+ while (i < s.length) {
526
+ if (s[i] === "\x1b" && i + 1 < s.length && s[i + 1] === "[") {
527
+ const close = s.indexOf("m", i + 2);
528
+ if (close !== -1) { i = close + 1; continue; }
529
+ const csiEnd = s.slice(i + 2).search(/[A-Za-z]/);
530
+ if (csiEnd !== -1) { i = i + 2 + csiEnd + 1; continue; }
531
+ }
532
+ if (s[i] === "[") {
533
+ const close = s.indexOf("]", i + 1);
534
+ if (close !== -1) {
535
+ const inner = s.slice(i + 1, close);
536
+ if (KNOWN_THEME_TAGS_FOR_BOX.has(inner) || (inner.startsWith("/") && KNOWN_THEME_TAGS_FOR_BOX.has(inner.slice(1)))) {
537
+ i = close + 1;
538
+ continue;
539
+ }
540
+ }
541
+ }
542
+ w += isWideChar(s[i]!) ? 2 : 1;
543
+ i++;
544
+ }
545
+ return w;
546
+ }
547
+
548
+ /** 给一行加 padding 到指定可见宽度。theme 标签和 ANSI 算 0 宽。 */
549
+ function padLineForBox(s: string, width: number): string {
550
+ const w = visibleWidthForBox(s);
551
+ if (w >= width) return s;
552
+ return s + " ".repeat(width - w);
553
+ }
554
+
555
+ /**
556
+ * 给一个 render 输出包外边框。
557
+ * @param th 主题(用 fg/bold)
558
+ * @param width 外框总可见宽度(包含两侧 `│` + 内部 1 格 padding)。< 4 时不画边框,直接返回 content。
559
+ * @param title 顶栏标题(可含 ANSI 主题色);传空字符串则不画标题,只用一根横线
560
+ * @param content 已 render 好的内容行(不含边框)
561
+ * @returns 加边框后的 lines
562
+ */
563
+ export function box(th: any, width: number, title: string, content: string[]): string[] {
564
+ // 太窄不画框(终端 < 6 列时画框会重叠)
565
+ if (width < 6) return content;
566
+ const innerW = width - 2; // `│` + 内容 + `│`
567
+ const padInnerW = innerW - 2; // 内部加 1 格左右 padding
568
+ const border = th.fg("borderMuted", "│");
569
+ const cornerTL = th.fg("borderMuted", "┌");
570
+ const cornerTR = th.fg("borderMuted", "┐");
571
+ const cornerBL = th.fg("borderMuted", "└");
572
+ const cornerBR = th.fg("borderMuted", "┘");
573
+ const hbar = th.fg("borderMuted", "─");
574
+
575
+ const out: string[] = [];
576
+ //
577
+ if (title) {
578
+ const titlePlain = ` ${title} `;
579
+ const titleW = visibleWidthForBox(titlePlain);
580
+ const leftFill = Math.max(1, Math.floor((innerW - titleW) / 2));
581
+ const rightFill = Math.max(0, innerW - leftFill - titleW);
582
+ out.push(
583
+ cornerTL
584
+ + hbar.repeat(leftFill)
585
+ + th.fg("accent", th.bold(titlePlain))
586
+ + hbar.repeat(rightFill)
587
+ + cornerTR,
588
+ );
589
+ } else {
590
+ out.push(cornerTL + hbar.repeat(innerW) + cornerTR);
591
+ }
592
+ // 中间
593
+ for (const ln of content) {
594
+ out.push(border + " " + padLineForBox(truncateForRender(ln, padInnerW), padInnerW) + " " + border);
595
+ }
596
+ //
597
+ out.push(cornerBL + hbar.repeat(innerW) + cornerBR);
598
+ return out;
599
+ }
600
+
601
+ // ============================================================================
602
+ // FormEditor 通用 TUI 单页表单编辑器
603
+ // ============================================================================
604
+
605
+ export type FormFieldType = "text" | "secret" | "select" | "number" | "readonly" | "json" | "levelmap" | "multiselect";
606
+
607
+ /** pi 的 thinking level 列表。levelmap 字段的勾选/取消就按这个顺序。 */
608
+ export const PI_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
609
+
610
+ export type FormField = {
611
+ key: string;
612
+ label: string;
613
+ type: FormFieldType;
614
+ options?: string[]; // for select
615
+ placeholder?: string;
616
+ hint?: string; // 显示在 value 后面的小提示
617
+ validate?: (value: unknown) => string | null; // 返回错误信息
618
+ render?: (value: unknown) => string; // 自定义显示(覆盖默认)
619
+ };
620
+
621
+ export class FormEditor<T extends Record<string, unknown>> {
622
+ private fields: FormField[];
623
+ private values: T;
624
+ private original: T;
625
+ private draft = "";
626
+ private draftIsOriginal = true; // draft == currentValue,未编辑过
627
+ private cursor = 0;
628
+ private levelmapCursor = 0; // levelmap 字段内的 row 位置(0-6)
629
+ private selectCursor = 0; // select 字段内的 options 位置
630
+ private multiselectCursor = 0; // multiselect 字段内的 options 位置
631
+ private editing = false; // 当前非输入字段是否在 edit 模式(Enter 进入、Enter/Space 退出)
632
+ private error: string | null = null;
633
+ private theme: any;
634
+ private title: string;
635
+ private onSave: (values: T) => void;
636
+ private onCancel: () => void;
637
+ private cachedWidth = -1;
638
+ private cachedLines: string[] = [];
639
+ /** 括包粘贴缓冲:pi 框架的 Ctrl+V / Alt+V / 右键粘贴会用 ESC[200~..ESC[201~ 包后 call handleInput。 */
640
+ private pasteBuf = new PasteBuffer();
641
+
642
+ constructor(opts: {
643
+ title: string;
644
+ fields: FormField[];
645
+ initial: T;
646
+ theme: any;
647
+ onSave: (values: T) => void;
648
+ onCancel: () => void;
649
+ }) {
650
+ this.title = opts.title;
651
+ this.fields = opts.fields;
652
+ this.values = JSON.parse(JSON.stringify(opts.initial)) as T;
653
+ this.original = JSON.parse(JSON.stringify(opts.initial)) as T;
654
+ this.theme = opts.theme;
655
+ this.onSave = opts.onSave;
656
+ this.onCancel = opts.onCancel;
657
+ this.draft = this.currentValueAsString();
658
+ this.draftIsOriginal = true;
659
+ // 粘贴:view 模式自动进 edit(避免被 view 模式接货的选中不动);仅 typeable 字段响应
660
+ this.pasteBuf.onPaste = (text) => {
661
+ if (!text) return;
662
+ const f0 = this.fields[this.cursor];
663
+ const isTypeable = f0 && (f0.type === "text" || f0.type === "secret" || f0.type === "number" || f0.type === "json");
664
+ if (!isTypeable) return;
665
+ if (!this.editing) {
666
+ // secret:跟 Enter 走同路径——草稿重置为原值,未修改标记
667
+ if (f0.type === "secret") {
668
+ const orig = (this.values as any)[f0.key];
669
+ this.draft = (orig === undefined || orig === null) ? "" : String(orig);
670
+ this.draftIsOriginal = true;
671
+ }
672
+ this.editing = true;
673
+ }
674
+ // number:过滤非数字,避免误粘 "123abc";secret / text / json 原样拼
675
+ const chunk = f0.type === "number" ? text.replace(/[^0-9]/g, "") : text;
676
+ if (!chunk) return;
677
+ if (f0.type === "number" && this.draftIsOriginal) this.draft = chunk; // 首次输入覆盖
678
+ else this.draft += chunk;
679
+ this.draftIsOriginal = false;
680
+ this.invalidate();
681
+ };
682
+ }
683
+
684
+ private currentValueAsString(): string {
685
+ const f = this.fields[this.cursor];
686
+ const v = this.values[f.key];
687
+ if (v === undefined || v === null) return "";
688
+ if (f.type === "secret") return v ? "••••" + String(v).slice(-2) : "";
689
+ if (f.type === "json") return v === null ? "" : JSON.stringify(v);
690
+ if (f.type === "levelmap") {
691
+ const m = (v && typeof v === "object") ? v as Record<string, string | null> : {};
692
+ const enabled = PI_LEVELS.filter((l) => m[l] != null);
693
+ return enabled.length ? enabled.join(", ") : "(none)";
694
+ }
695
+ if (f.type === "multiselect") {
696
+ const arr = Array.isArray(v) ? v as string[] : [];
697
+ return arr.length ? arr.join(", ") : "(none)";
698
+ }
699
+ return String(v);
700
+ }
701
+
702
+ private commitDraft(): { ok: boolean } {
703
+ const f = this.fields[this.cursor];
704
+ if (f.type === "readonly") return { ok: true };
705
+ if (f.type === "levelmap") {
706
+ // 保证 7 level 都写进 values:勾选的写 level 名,未勾选的写 null(避岀 cycle)
707
+ const cur = ((this.values as any)[f.key] ?? {}) as Record<string, string | null>;
708
+ const normalized: Record<string, string | null> = {};
709
+ for (const lv of PI_LEVELS) {
710
+ const v = cur[lv];
711
+ normalized[lv] = v === undefined ? null : v;
712
+ }
713
+ (this.values as any)[f.key] = normalized;
714
+ return { ok: true };
715
+ }
716
+ if (f.type === "multiselect") return { ok: true }; // 直接操作 values,不需 draft
717
+ const raw = this.draft;
718
+ if (f.type === "number") {
719
+ // 严格校验:draft 必须全数字(或空 走默认值 0)
720
+ if (!/^\d+$/.test(raw)) { this.error = `${f.label}: must be a non-negative integer`; return { ok: false }; }
721
+ const n = parseInt(raw, 10);
722
+ if (f.validate) {
723
+ const err = f.validate(n);
724
+ if (err) { this.error = `${f.label}: ${err}`; return { ok: false }; }
725
+ }
726
+ (this.values as any)[f.key] = n;
727
+ } else if (f.type === "json") {
728
+ try {
729
+ const parsed = raw.trim() ? JSON.parse(raw) : null;
730
+ if (f.validate) {
731
+ const err = f.validate(parsed);
732
+ if (err) { this.error = `${f.label}: ${err}`; return { ok: false }; }
733
+ }
734
+ (this.values as any)[f.key] = parsed;
735
+ } catch (err) {
736
+ this.error = `${f.label}: invalid JSON (${err instanceof Error ? err.message : err})`;
737
+ return { ok: false };
738
+ }
739
+ } else if (f.type === "select") {
740
+ if (raw && !f.options?.includes(raw)) {
741
+ // 宽松容错:true/yes → options 中的"yes";false/no → "no";其它字符串报错
742
+ if (raw === "true" || raw === "yes") { (this.values as any)[f.key] = (f.options ?? []).find((o) => o === "yes") ?? raw; }
743
+ else if (raw === "false" || raw === "no") { (this.values as any)[f.key] = (f.options ?? []).find((o) => o === "no") ?? raw; }
744
+ else { this.error = `${f.label}: must be one of ${(f.options ?? []).join(", ")}`; return { ok: false }; }
745
+ } else if (raw) { (this.values as any)[f.key] = raw; }
746
+ if (f.validate) { const err = f.validate(raw); if (err) { this.error = `${f.label}: ${err}`; return { ok: false }; } }
747
+ } else { // text, secret
748
+ if (f.validate) { const err = f.validate(raw); if (err) { this.error = `${f.label}: ${err}`; return { ok: false }; } }
749
+ // secret: 避免覆写原值。draft masked display ("••••Xn"),如果用户没改(draftIsOriginal=true),
750
+ // field / Enter 退出 edit 都会调 commitDraft,不跳这会写回 masked 字符串覆盖真 key。
751
+ if (f.type === "secret" && this.draftIsOriginal) {
752
+ // no-op,保持 values[f.key] 原值
753
+ } else {
754
+ (this.values as any)[f.key] = raw;
755
+ }
756
+ }
757
+ this.error = null;
758
+ return { ok: true };
759
+ }
760
+
761
+ private move(delta: number): void {
762
+ const result = this.commitDraft();
763
+ this.cursor = (this.cursor + delta + this.fields.length) % this.fields.length;
764
+ // levelmap/select 字段专用的 sub-cursor:进入/离开都重置
765
+ // levelmap 始终 0;select 设为当前 value 在 options 里的 index(找不到则 0)
766
+ this.levelmapCursor = 0;
767
+ const f = this.fields[this.cursor];
768
+ if (f?.type === "select" && f.options) {
769
+ const cur = this.values[f.key] as string | undefined;
770
+ const idx = cur ? f.options.indexOf(cur) : -1;
771
+ this.selectCursor = idx >= 0 ? idx : 0;
772
+ } else {
773
+ this.selectCursor = 0;
774
+ }
775
+ this.draft = this.currentValueAsString();
776
+ this.draftIsOriginal = true;
777
+ // 仅在 commit 成功时清 error;失败时保留让用户看到
778
+ if (result.ok) this.error = null;
779
+ this.invalidate();
780
+ }
781
+
782
+ /** levelmap 字段:切换当前 row enable/disable。enable level 名做 value,disable 写 null(避免该 level 进入 cycle)。 */
783
+ private toggleLevelmapRow(key: string): void {
784
+ const level = PI_LEVELS[this.levelmapCursor];
785
+ const cur = ((this.values as any)[key] ?? {}) as Record<string, string | null>;
786
+ const next: Record<string, string | null> = { ...cur };
787
+ // 保证所有 7 level 都有 key(未勾选的全部以 null 补上,进不了 cycle)
788
+ for (const lv of PI_LEVELS) {
789
+ if (next[lv] === undefined) next[lv] = null;
790
+ }
791
+ if (cur[level]) next[level] = null; // 当前 enabled 写 null(退出 cycle)
792
+ else next[level] = level; // 当前 disabled enable,value = level
793
+ (this.values as any)[key] = next;
794
+ this.draftIsOriginal = false;
795
+ this.invalidate();
796
+ }
797
+
798
+ handleInput(data: string): void {
799
+ // 括包粘贴:Ctrl+V / Alt+V / 右键粘走 PasteBuffer(onPaste 检查 editing + typeable)
800
+ if (this.pasteBuf.feed(data)) {
801
+ const rest = this.pasteBuf.consumeRest();
802
+ if (rest) this.processRest(rest);
803
+ return;
804
+ }
805
+ const f0 = this.fields[this.cursor];
806
+ const isNonInput = f0?.type === "select" || f0?.type === "levelmap" || f0?.type === "multiselect";
807
+ const isTypeable = f0 && (f0.type === "text" || f0.type === "secret" || f0.type === "number" || f0.type === "json");
808
+ const isReadonly = f0?.type === "readonly";
809
+
810
+ // 统一模型:所有字段都遵 view / edit 两态。
811
+ // view(editing=false,默认):只响应 Enter(进 edit)、↑↓/j/k(切字段)、s(保存)、Esc/q(取消)
812
+ // edit(editing=true):可修改(typeable 输字符 / non-typeable 按 Space/↑↓)、Enter(提交+退出)、Esc(退出)、↑↓(提交+切字段)
813
+
814
+ // 1. Esc / q edit 中退出(commit);view 模式取消整个 form
815
+ if (matchesKey(data, "escape") || data === "q") {
816
+ if (this.editing) {
817
+ this.commitDraft();
818
+ this.editing = false;
819
+ this.invalidate();
820
+ return;
821
+ }
822
+ this.onCancel();
823
+ return;
824
+ }
825
+
826
+ // 2. s 保存整个 form(仅在 view 模式;edit 模式下 s 是字符 / no-op)
827
+ if (data === "s" && !this.editing) {
828
+ const result = this.commitDraft();
829
+ if (!result.ok) { this.invalidate(); return; }
830
+ this.onSave(this.values);
831
+ return;
832
+ }
833
+
834
+ // 3. Enter toggle edit(readonly 不响应)
835
+ // view: edit(所有 typeable / non-typeable)
836
+ // edit: commit + 退出 edit(不走下一字段,留在原字段;用户用 ↑↓ 切)
837
+ if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
838
+ if (isReadonly) return;
839
+ if (this.editing) {
840
+ this.commitDraft();
841
+ this.editing = false;
842
+ this.invalidate();
843
+ return;
844
+ }
845
+ // 进 edit。secret field:draft = 原值(不是 masked 显示),用户可看/可改真 key;
846
+ // commitDraft 会配合 draftIsOriginal 避免覆写未改的原值。
847
+ if (f0?.type === "secret") {
848
+ const orig = (this.values as any)[f0.key];
849
+ this.draft = (orig === undefined || orig === null) ? "" : String(orig);
850
+ this.draftIsOriginal = true;
851
+ }
852
+ this.editing = true;
853
+ this.invalidate();
854
+ return;
855
+ }
856
+
857
+ // 4. ↑↓:
858
+ // view / edit + typeable:切字段(edit 模式下 commit + 退 edit)
859
+ // edit + non-typeable:在 step 7 处理(options 内 nav)
860
+ if (matchesKey(data, "down") || matchesKey(data, "up")) {
861
+ if (!(this.editing && isNonInput)) {
862
+ this.commitDraft();
863
+ this.move(matchesKey(data, "up") ? -1 : 1);
864
+ this.editing = false;
865
+ return;
866
+ }
867
+ }
868
+
869
+ // 5. j/k:
870
+ // view:切字段
871
+ // edit + typeable:字符(下面 printable 处理)
872
+ // edit + non-typeable:options 内 nav(在 step 7 处理)
873
+ if ((data === "j" || data === "k") && !this.editing) {
874
+ this.move(data === "j" ? 1 : -1);
875
+ return;
876
+ }
877
+
878
+ // 6. Space:view + non-typeable 快捷进 edit(与 Enter 等价)
879
+ if (data === " " && isNonInput && !this.editing) {
880
+ this.editing = true;
881
+ this.invalidate();
882
+ return;
883
+ }
884
+
885
+ // 7. edit + non-typeable:↑↓/j/k 在 options 内移动,Space pick
886
+ if (this.editing && isNonInput) {
887
+ if (f0!.type === "levelmap") {
888
+ if (matchesKey(data, "down") || data === "j") {
889
+ if (this.levelmapCursor < 6) { this.levelmapCursor++; this.invalidate(); }
890
+ return;
891
+ }
892
+ if (matchesKey(data, "up") || data === "k") {
893
+ if (this.levelmapCursor > 0) { this.levelmapCursor--; this.invalidate(); }
894
+ return;
895
+ }
896
+ if (data === " ") {
897
+ this.toggleLevelmapRow(f0!.key);
898
+ return;
899
+ }
900
+ return; // edit 模式下其他键不响应
901
+ }
902
+ if (f0!.type === "select" && f0!.options) {
903
+ if (matchesKey(data, "down") || data === "j") {
904
+ this.selectCursor = Math.min(this.selectCursor + 1, f0!.options.length - 1);
905
+ this.invalidate();
906
+ return;
907
+ }
908
+ if (matchesKey(data, "up") || data === "k") {
909
+ this.selectCursor = Math.max(this.selectCursor - 1, 0);
910
+ this.invalidate();
911
+ return;
912
+ }
913
+ if (data === " ") {
914
+ (this.values as any)[f0!.key] = f0!.options[this.selectCursor];
915
+ this.draft = f0!.options[this.selectCursor];
916
+ this.draftIsOriginal = true;
917
+ this.invalidate();
918
+ return;
919
+ }
920
+ return; // edit 模式下其他键不响应
921
+ }
922
+ // multiselect 字段:↑↓/j/k 选 option,Space toggle(在值里加/去)
923
+ if (f0!.type === "multiselect" && f0!.options) {
924
+ const opts = f0!.options;
925
+ if (matchesKey(data, "down") || data === "j") {
926
+ this.multiselectCursor = Math.min(this.multiselectCursor + 1, opts.length - 1);
927
+ this.invalidate();
928
+ return;
929
+ }
930
+ if (matchesKey(data, "up") || data === "k") {
931
+ this.multiselectCursor = Math.max(this.multiselectCursor - 1, 0);
932
+ this.invalidate();
933
+ return;
934
+ }
935
+ if (data === " ") {
936
+ const opt = opts[this.multiselectCursor];
937
+ const cur = (this.values[f0!.key] && Array.isArray(this.values[f0!.key])) ? this.values[f0!.key] as string[] : [];
938
+ const idx = cur.indexOf(opt);
939
+ const next = idx >= 0 ? cur.filter((_, i) => i !== idx) : [...cur, opt];
940
+ (this.values as any)[f0!.key] = next;
941
+ this.draftIsOriginal = false;
942
+ this.invalidate();
943
+ return;
944
+ }
945
+ return;
946
+ }
947
+ }
948
+
949
+ // 8. readonly:后续输入不响应
950
+ if (isReadonly) return;
951
+
952
+ // 9. view 模式:不接受任何字符输入(需先 Enter 进 edit)
953
+ if (!this.editing) return;
954
+
955
+ // 10. Backspace:仅在 edit + typeable 删除 draft
956
+ if (matchesKey(data, "backspace")) {
957
+ if (!isTypeable) return;
958
+ if (this.draft.length > 0) this.draft = this.draft.slice(0, -1);
959
+ this.draftIsOriginal = false;
960
+ this.invalidate();
961
+ return;
962
+ }
963
+
964
+ // 11. 可打印字符:仅在 edit + typeable 追加 draft
965
+ if (data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) < 127) {
966
+ if (!isTypeable) return;
967
+ // number 字段:第一个数字替换(避免 100 + "2" = 1002),后续 append
968
+ if (f0?.type === "number" && /^\d$/.test(data) && this.draftIsOriginal) {
969
+ this.draft = data;
970
+ } else {
971
+ this.draft += data;
972
+ }
973
+ this.draftIsOriginal = false;
974
+ this.invalidate();
975
+ return;
976
+ }
977
+ }
978
+
979
+ /** 处理括包粘贴后剩余的字符串:按字符逐个调 handleInput(不走 PasteBuffer 路径)。 */
980
+ private processRest(rest: string): void {
981
+ for (const ch of rest) this.dispatchChar(ch);
982
+ }
983
+
984
+ /** 单个字符的常规处理(仅 typeable 字符 / Backspace)。其他键不响应。 */
985
+ private dispatchChar(ch: string): void {
986
+ const f0 = this.fields[this.cursor];
987
+ const isTypeable = f0 && (f0.type === "text" || f0.type === "secret" || f0.type === "number" || f0.type === "json");
988
+ if (matchesKey(ch, "backspace")) {
989
+ if (!this.editing || !isTypeable) return;
990
+ if (this.draft.length > 0) this.draft = this.draft.slice(0, -1);
991
+ this.draftIsOriginal = false;
992
+ this.invalidate();
993
+ return;
994
+ }
995
+ if (!this.editing || !isTypeable) return;
996
+ if (ch.length === 1 && ch.charCodeAt(0) >= 32 && ch.charCodeAt(0) < 127) {
997
+ if (f0?.type === "number" && /^\d$/.test(ch) && this.draftIsOriginal) {
998
+ this.draft = ch;
999
+ } else {
1000
+ this.draft += ch;
1001
+ }
1002
+ this.draftIsOriginal = false;
1003
+ this.invalidate();
1004
+ }
1005
+ }
1006
+
1007
+ invalidate(): void { this.cachedWidth = -1; this.cachedLines = []; }
1008
+
1009
+ private formatValue(f: FormField, v: unknown): string {
1010
+ if (f.render) return f.render(v);
1011
+ if (v === undefined || v === null) return "";
1012
+ if (f.type === "secret") return v ? "••••" + String(v).slice(-2) : "";
1013
+ if (f.type === "json") return v === null ? "" : JSON.stringify(v);
1014
+ if (f.type === "levelmap") {
1015
+ const m = (v && typeof v === "object") ? v as Record<string, string | null> : {};
1016
+ const enabled = PI_LEVELS.filter((l) => m[l] != null);
1017
+ return enabled.length ? enabled.join(", ") : "(none)";
1018
+ }
1019
+ if (f.type === "multiselect") {
1020
+ const arr = Array.isArray(v) ? v as string[] : [];
1021
+ return arr.length ? arr.join(", ") : "(none)";
1022
+ }
1023
+ return String(v);
1024
+ }
1025
+
1026
+ /** 渲染 select 字段:active 时展开所有 options(cursor 行高亮),非 active 时显示当前值 */
1027
+ private renderSelect(f: FormField, isActive: boolean, _labelW: number, width: number): string[] {
1028
+ const th = this.theme;
1029
+ const out: string[] = [];
1030
+ const opts = f.options ?? [];
1031
+ const cur = this.values[f.key] as string | undefined;
1032
+ const label = (f.label + ":").padEnd(_labelW + 2);
1033
+ if (!isActive || (isActive && !this.editing)) {
1034
+ const valueStr = cur ? th.fg("text", cur) : th.fg("muted", "(unset)");
1035
+ const prefix = isActive ? th.fg("accent", "▸ ") : " ";
1036
+ out.push(truncateForRender(prefix + th.bold(label) + valueStr, width));
1037
+ return out;
1038
+ }
1039
+ // active 且 editing:label 行(带 [● edit])+ options 列表
1040
+ const editMarker = th.fg("accent", " [● edit]");
1041
+ out.push(truncateForRender(th.fg("accent", "▸ ") + th.bold(label) + editMarker, width));
1042
+ for (let i = 0; i < opts.length; i++) {
1043
+ const isCursor = i === this.selectCursor;
1044
+ const isCurrent = opts[i] === cur;
1045
+ const arrow = isCursor ? th.fg("accent", "→ ") : " ";
1046
+ const box = isCurrent ? th.fg("success", "[√]") : th.fg("dim", "[ ]");
1047
+ const optStr = isCurrent ? th.fg("text", opts[i]) : (isCursor ? th.bold(opts[i]) : th.fg("muted", opts[i]));
1048
+ out.push(truncateForRender(" " + arrow + box + " " + optStr, width));
1049
+ }
1050
+ return out;
1051
+ }
1052
+
1053
+ /** 渲染 multiselect 字段:active 且 editing=true 时展开所有 options([√/] 标记已选项 + [● edit]),active 但未 editing 时显示单行,non-active 时显示单行 */
1054
+ private renderMultiselect(f: FormField, isActive: boolean, label: string, _labelW: number, width: number): string[] {
1055
+ const th = this.theme;
1056
+ const out: string[] = [];
1057
+ const opts = f.options ?? [];
1058
+ const cur = (this.values[f.key] && Array.isArray(this.values[f.key])) ? this.values[f.key] as string[] : [];
1059
+ if (!isActive || (isActive && !this.editing)) {
1060
+ const valueStr = cur.length ? th.fg("text", cur.join(", ")) : th.fg("muted", "(none)");
1061
+ const prefix = isActive ? th.fg("accent", "▸ ") : " ";
1062
+ out.push(truncateForRender(prefix + th.bold(label) + valueStr, width));
1063
+ return out;
1064
+ }
1065
+ // active 且 editing:label 行(带 [● edit])+ options 列表
1066
+ const editMarker = th.fg("accent", " [● edit]");
1067
+ out.push(truncateForRender(th.fg("accent", "▸ ") + th.bold(label) + editMarker, width));
1068
+ for (let i = 0; i < opts.length; i++) {
1069
+ const opt = opts[i];
1070
+ const isCurrent = cur.indexOf(opt) >= 0;
1071
+ const isCursor = i === this.multiselectCursor;
1072
+ const arrow = isCursor ? th.fg("accent", "→ ") : " ";
1073
+ const box = isCurrent ? th.fg("success", "[√]") : th.fg("dim", "[ ]");
1074
+ const optStr = isCurrent ? th.fg("text", opt) : (isCursor ? th.bold(opt) : th.fg("muted", opt));
1075
+ out.push(truncateForRender(" " + arrow + box + " " + optStr, width));
1076
+ }
1077
+ return out;
1078
+ }
1079
+ /** 渲染 levelmap 字段:active 时展开 7 行(首行带 label),非 active 时显示当前 enabled 的 level 列表 */
1080
+ private renderLevelmap(f: FormField, isActive: boolean, label: string, _labelW: number, width: number): string[] {
1081
+ const th = this.theme;
1082
+ const out: string[] = [];
1083
+ const cur = ((this.values[f.key] ?? {}) as Record<string, string | null>);
1084
+ if (!isActive || (isActive && !this.editing)) {
1085
+ // 非 active / active 未 edit:单行显示
1086
+ const enabled = PI_LEVELS.filter((l) => cur[l] != null);
1087
+ const valueStr = enabled.length ? th.fg("text", enabled.join(", ")) : th.fg("muted", "(none)");
1088
+ const prefix = isActive ? th.fg("accent", "▸ ") : " ";
1089
+ const line = prefix + th.bold(label) + valueStr;
1090
+ out.push(truncateForRender(line, width));
1091
+ return out;
1092
+ }
1093
+ // active 且 editing:label 行(带 [● edit])+ 7 行 level
1094
+ const editMarker = th.fg("accent", " [● edit]");
1095
+ out.push(truncateForRender(th.fg("accent", "▸ ") + th.bold(label) + editMarker, width));
1096
+ for (let i = 0; i < PI_LEVELS.length; i++) {
1097
+ const level = PI_LEVELS[i];
1098
+ const isOn = cur[level] != null;
1099
+ const box = isOn ? th.fg("success", "[√]") : th.fg("dim", "[ ]");
1100
+ const isCursor = i === this.levelmapCursor;
1101
+ const arrow = isCursor ? th.fg("accent", "→ ") : " ";
1102
+ const levelStr = isOn ? th.fg("text", level) : (isCursor ? th.bold(level) : th.fg("dim", level));
1103
+ const line = " " + arrow + box + " " + levelStr;
1104
+ out.push(truncateForRender(line, width));
1105
+ }
1106
+ return out;
1107
+ }
1108
+
1109
+ render(width: number): string[] {
1110
+ if (this.cachedWidth === width && this.cachedLines.length > 0) return this.cachedLines;
1111
+ const th = this.theme;
1112
+ const body: string[] = [];
1113
+
1114
+ // box 外边框占 4 列(│×2 + 内边距×2),内容按 width-4 布局避免套框超宽
1115
+ const cw = Math.max(20, width - 4);
1116
+
1117
+ body.push(th.fg("accent", th.bold(` ${this.title} `)) + th.fg("borderMuted", "─".repeat(Math.max(0, cw - this.title.length - 4))));
1118
+ if (this.error) body.push(th.fg("error", ` ⚠ ${this.error}`));
1119
+ body.push("");
1120
+
1121
+ const labelW = Math.max(...this.fields.map((x) => x.label.length));
1122
+ for (let i = 0; i < this.fields.length; i++) {
1123
+ const f = this.fields[i];
1124
+ const isActive = i === this.cursor;
1125
+ // levelmap 字段:active 时展开 7 行;其他字段同原来
1126
+ if (f.type === "levelmap") {
1127
+ const label = (f.label + ":").padEnd(labelW + 2);
1128
+ const linesForLevelmap = this.renderLevelmap(f, isActive, label, labelW, cw);
1129
+ for (const ln of linesForLevelmap) body.push(ln);
1130
+ continue;
1131
+ }
1132
+ if (f.type === "select" && f.options) {
1133
+ const linesForSelect = this.renderSelect(f, isActive, labelW, cw);
1134
+ for (const ln of linesForSelect) body.push(ln);
1135
+ continue;
1136
+ }
1137
+ if (f.type === "multiselect" && f.options) {
1138
+ const label = (f.label + ":").padEnd(labelW + 2);
1139
+ const linesForMulti = this.renderMultiselect(f, isActive, label, labelW, cw);
1140
+ for (const ln of linesForMulti) body.push(ln);
1141
+ continue;
1142
+ }
1143
+ const raw = isActive ? this.draft : this.formatValue(f, this.values[f.key]);
1144
+ const isEmpty = !raw;
1145
+ const label = (f.label + ":").padEnd(labelW + 2);
1146
+ const labelStr = isActive ? th.bold(label) : label;
1147
+ let valueStr: string;
1148
+ if (f.type === "secret" && !isActive) {
1149
+ valueStr = isEmpty ? th.fg("dim", "(empty)") : th.fg("dim", raw);
1150
+ } else if (isEmpty) {
1151
+ valueStr = th.fg("muted", isActive ? "" : "(empty)");
1152
+ } else {
1153
+ valueStr = isActive ? raw : th.fg("text", raw);
1154
+ }
1155
+ const prefix = isActive ? th.fg("accent", "▸ ") : " ";
1156
+ const editMarker = isActive && this.editing ? th.fg("accent", " [● edit]") : "";
1157
+ const hint = f.hint ? " " + th.fg("muted", f.hint) : "";
1158
+ const line = prefix + labelStr + valueStr + editMarker + hint;
1159
+ body.push(truncateForRender(line, cw));
1160
+ }
1161
+
1162
+ body.push("");
1163
+ body.push(th.fg("borderMuted", "─".repeat(cw)));
1164
+ const f = this.fields[this.cursor];
1165
+ const hints: string[] = ["↑↓ field"];
1166
+ if (f?.type === "multiselect") hints.push(this.editing ? "↑↓ option · Space toggle · Enter commit" : "Enter edit · Space toggle");
1167
+ else if (f?.type === "select") hints.push(this.editing ? "↑↓ option · Space pick · Enter commit" : "Enter edit · Space pick");
1168
+ else if (f?.type === "levelmap") hints.push(this.editing ? "↑↓ level · Space toggle · Enter commit" : "Enter edit · Space toggle");
1169
+ else if (f?.type === "readonly") hints.push("readonly");
1170
+ else hints.push(this.editing ? "type to edit" : "Enter edit · type");
1171
+ hints.push(this.editing && f && (f.type === "text" || f.type === "secret" || f.type === "number" || f.type === "json") ? "Backspace del" : "Backspace");
1172
+ hints.push(this.editing ? "Enter commit" : "s save");
1173
+ hints.push("Esc cancel");
1174
+ // footer 按 cw 软换行,避免 box 截断丢尾部键位
1175
+ let hintLine = "";
1176
+ for (const p of hints) {
1177
+ const cand = hintLine ? hintLine + " · " + p : " " + p;
1178
+ if (cand.length > cw && hintLine) {
1179
+ body.push(th.fg("dim", hintLine));
1180
+ hintLine = " " + p;
1181
+ } else {
1182
+ hintLine = cand;
1183
+ }
1184
+ }
1185
+ body.push(th.fg("dim", hintLine));
1186
+
1187
+ // 外边框:浮窗加 box,让 tui 里的 overlay 看起来不糊
1188
+ const lines = box(th, width, this.title, body);
1189
+ this.cachedWidth = width;
1190
+ this.cachedLines = lines;
1191
+ return lines;
1192
+ }
1193
+ }