@fanchaozz/provider-manager 0.2.3 → 1.0.1

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 CHANGED
@@ -35,6 +35,77 @@ export function matchesKey(data: string, key: string): boolean {
35
35
  return false;
36
36
  }
37
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
+
38
109
  // ============================================================================
39
110
  // ModelChecklist — 多选 checklist(sync 用)
40
111
  // ============================================================================
@@ -45,28 +116,44 @@ export type ChecklistItem = {
45
116
  disabled?: boolean; // true = 显示但不让选
46
117
  };
47
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
+
48
126
  export class ModelChecklist {
49
127
  private items: ChecklistItem[];
50
128
  private selected: Set<string>;
51
129
  private cursor = 0;
130
+ private top = 0;
52
131
  private title: string;
53
132
  private theme: any;
133
+ private maxRows: number;
54
134
  private onConfirm: (selected: string[]) => void;
55
135
  private onCancel: () => void;
56
136
  private cachedWidth = -1;
57
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 = "";
58
142
 
59
143
  constructor(opts: {
60
144
  title: string;
61
145
  items: ChecklistItem[];
62
146
  theme: any;
63
147
  preSelect?: (item: ChecklistItem) => boolean; // 默认全选时筛掉不想要的
148
+ maxRows?: number;
64
149
  onConfirm: (selected: string[]) => void;
65
150
  onCancel: () => void;
66
151
  }) {
67
152
  this.title = opts.title;
68
153
  this.items = opts.items;
69
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));
70
157
  this.onConfirm = opts.onConfirm;
71
158
  this.onCancel = opts.onCancel;
72
159
  this.selected = new Set();
@@ -75,9 +162,44 @@ export class ModelChecklist {
75
162
  if (opts.preSelect && !opts.preSelect(it)) continue;
76
163
  this.selected.add(it.id);
77
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;
78
194
  }
79
195
 
80
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
+ }
81
203
  if (matchesKey(data, "escape") || data === "q") {
82
204
  this.onCancel();
83
205
  return;
@@ -86,8 +208,28 @@ export class ModelChecklist {
86
208
  this.onConfirm([...this.selected]);
87
209
  return;
88
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
+ }
89
231
  if (data === " ") {
90
- const it = this.items[this.cursor];
232
+ const it = visible[this.cursor];
91
233
  if (it && !it.disabled) {
92
234
  if (this.selected.has(it.id)) this.selected.delete(it.id);
93
235
  else this.selected.add(it.id);
@@ -95,32 +237,76 @@ export class ModelChecklist {
95
237
  }
96
238
  return;
97
239
  }
240
+ // wrap-around:顶部 ↑ 跳到末项、底部 ↓ 跳回首项(和 pi 的 /models 一致)
98
241
  if (matchesKey(data, "down") || data === "j") {
99
- this.cursor = Math.min(this.cursor + 1, this.items.length - 1);
242
+ this.cursor = (this.cursor + 1) % visible.length;
100
243
  this.invalidate();
101
- } else if (matchesKey(data, "up") || data === "k") {
102
- this.cursor = Math.max(this.cursor - 1, 0);
244
+ return;
245
+ }
246
+ if (matchesKey(data, "up") || data === "k") {
247
+ this.cursor = (this.cursor - 1 + visible.length) % visible.length;
103
248
  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);
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;
109
270
  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;
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;
124
310
  this.invalidate();
125
311
  }
126
312
  }
@@ -132,53 +318,147 @@ export class ModelChecklist {
132
318
  const th = this.theme;
133
319
  const lines: string[] = [];
134
320
 
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
321
  const total = this.items.length;
139
322
  const sel = this.selected.size;
140
323
  const disabled = this.items.filter((it) => it.disabled).length;
324
+ const visible = this.visibleItems();
325
+ const visCount = visible.length;
326
+
327
+ // Header:title + 上划线
328
+ const head = th.fg("accent", th.bold(` ${this.title} `)) + th.fg("borderMuted", "─".repeat(Math.max(0, width - this.title.length - 4)));
329
+ lines.push(head);
330
+ // 顶部 selected / total 状态
141
331
  const summary = ` ${sel}/${total} selected${disabled ? ` (${disabled} existing, skipped)` : ""} `;
142
332
  lines.push(th.fg("dim", summary));
333
+ // search 输入框(始终显示)
334
+ const queryW = Math.max(0, width - 4);
335
+ const queryShown = this.query.length > queryW ? this.query.slice(0, queryW) : this.query;
336
+ // pi 的 /models 风格:"> " 作为 prompt,"|" 是 cursor。query 末尾加 "▏" 作为 placeholder 提示。
337
+ const queryLine = th.fg("accent", " > ") + (this.query ? th.fg("text", queryShown) : th.fg("muted", "▏"));
338
+ lines.push(truncateForRender(queryLine, width));
143
339
  lines.push("");
144
340
 
145
- // List
146
- if (total === 0) {
147
- lines.push(th.fg("dim", " (no models to choose from)"));
341
+ // 列表区
342
+ // 布局:visible.length > maxRows 时顶部钉住首项 + (top),多占 2 行。
343
+ // 这样 mid-list 滚动也能看到 “列表起点是谁”
344
+ const itemLines: string[] = [];
345
+ let moreBelow: string | null = null;
346
+ if (visCount === 0) {
347
+ itemLines.push(th.fg("dim", this.query
348
+ ? ` (no models match "${this.query}")`
349
+ : " (no models to choose from)"));
148
350
  } else {
149
- for (let i = 0; i < this.items.length; i++) {
150
- const it = this.items[i];
351
+ const needPinOutside = this.cursor >= this.maxRows;
352
+ // 钉住首项占 1 行。有效视口 = maxRows - 1(未钉住时 = maxRows)
353
+ const effectiveViewport = Math.max(1, this.maxRows - (needPinOutside ? 1 : 0));
354
+ this.adjustTop(effectiveViewport);
355
+ const viewStart = this.top;
356
+ const viewEnd = Math.min(visCount, viewStart + effectiveViewport);
357
+
358
+ if (needPinOutside) {
359
+ const first = visible[0];
360
+ const box = first.disabled ? th.fg("dim", "[skip]")
361
+ : this.selected.has(first.id) ? th.fg("success", "[√]")
362
+ : th.fg("dim", "[ ]");
363
+ itemLines.push(truncateForRender(` ${box} ${first.id} ${th.fg("dim", "(top)")}`, width));
364
+ // hidden 计数:剩下未在钉住首项 / viewport 中展示的项
365
+ // = total - (1 pinned + viewport) = total - maxRows
366
+ const hiddenTotal = visCount - 1 - (viewEnd - viewStart);
367
+ if (hiddenTotal > 0) {
368
+ itemLines.push(th.fg("dim", ` ⋮ ${hiddenTotal} hidden`));
369
+ }
370
+ }
371
+ for (let i = viewStart; i < viewEnd; i++) {
372
+ const it = visible[i];
151
373
  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", "[ ]");
374
+ const arrow = isCursor ? th.fg("accent", " ") : " ";
375
+ const box = it.disabled ? th.fg("dim", "[skip]")
376
+ : this.selected.has(it.id) ? th.fg("success", "[]")
377
+ : th.fg("dim", "[ ]");
157
378
  const id = isCursor ? th.bold(it.id) : it.id;
379
+ const topLabel = (i === 0 && !needPinOutside) ? th.fg("dim", " (top)") : "";
158
380
  const sub = it.label ? " " + th.fg("muted", it.label) : "";
159
- lines.push(truncateForRender(`${arrow}${box} ${id}${sub}`, width));
381
+ itemLines.push(truncateForRender(`${arrow}${box} ${id}${topLabel}${sub}`, width));
160
382
  }
383
+ if (viewEnd < visCount) {
384
+ const nextId = visible[viewEnd]?.id ?? "";
385
+ moreBelow = ` ⋮ ${visCount - viewEnd} more below (${nextId} …)`;
386
+ }
387
+ }
388
+
389
+ // items 先
390
+ for (const l of itemLines) lines.push(l);
391
+ if (moreBelow) lines.push(moreBelow);
392
+
393
+ // 位置指示:i / visCount,与 pi 的 /models 一致
394
+ // 注意:query 为空时 visCount = total,仍能告知总长度;query 非空时为过滤后位置
395
+ if (visCount > 0) {
396
+ lines.push(th.fg("muted", ` (${this.cursor + 1}/${visCount})`));
161
397
  }
162
398
 
399
+ // chrome 置底
163
400
  lines.push("");
164
401
  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"));
402
+ // 底部提示词:search 始终是首选,所以 a/i/g/G 不再是快捷键。
403
+ lines.push(th.fg("dim", " type to filter · Space toggle · ↑↓/jk nav (wrap) · Backspace del · Enter apply · Esc cancel"));
404
+
166
405
  this.cachedWidth = width;
167
406
  this.cachedLines = lines;
168
407
  return lines;
169
408
  }
170
409
  }
171
410
 
172
- /** 给 render 用的截断(不剥主题,简化版;与 ui.ts truncateToWidth 行为一致) */
411
+ /** 给 render 用的截断。计算宽度时跳过 ANSI 转义序列 + 已知主题标签,避免裁到一半丢颜色。 */
173
412
  function truncateForRender(s: string, width: number): string {
174
413
  if (width <= 0) return "";
414
+ const KNOWN_THEME_TAGS = new Set<string>([
415
+ "accent", "warning", "dim", "success", "error", "muted", "text",
416
+ "borderMuted", "border", "borderAccent",
417
+ "background", "primary", "secondary",
418
+ "toolTitle", "toolOutput", "toolBg",
419
+ "customMessageBg", "userMessageBg", "thinking",
420
+ "bold", "italic", "underline", "inverse",
421
+ "selection", "comment", "keyword", "string", "number", "function",
422
+ "variable", "type", "operator", "punctuation", "property",
423
+ ]);
175
424
  let w = 0;
176
425
  let out = "";
177
- for (const ch of s) {
426
+ let i = 0;
427
+ while (i < s.length) {
428
+ // ANSI 转义序列:\x1b[ ... m(零宽)
429
+ if (s[i] === "\x1b" && i + 1 < s.length && s[i + 1] === "[") {
430
+ const close = s.indexOf("m", i + 2);
431
+ if (close !== -1) {
432
+ out += s.slice(i, close + 1);
433
+ i = close + 1;
434
+ continue;
435
+ }
436
+ // 其他 CSI 序列:结尾是某个字母
437
+ const tail = s.slice(i + 2).search(/[A-Za-z]/);
438
+ if (tail !== -1) {
439
+ out += s.slice(i, i + 2 + tail + 1);
440
+ i = i + 2 + tail + 1;
441
+ continue;
442
+ }
443
+ }
444
+ // 旧主题标签 [tag] / [/tag]:仅白名单内的零宽
445
+ if (s[i] === "[") {
446
+ const close = s.indexOf("]", i + 1);
447
+ if (close !== -1) {
448
+ const inner = s.slice(i + 1, close);
449
+ if (KNOWN_THEME_TAGS.has(inner) || (inner.startsWith("/") && KNOWN_THEME_TAGS.has(inner.slice(1)))) {
450
+ out += s.slice(i, close + 1);
451
+ i = close + 1;
452
+ continue;
453
+ }
454
+ }
455
+ }
456
+ const ch = s[i]!;
178
457
  const cw = isWideChar(ch) ? 2 : 1;
179
458
  if (w + cw > width) return out + (w + 1 <= width ? "…" : "");
180
459
  out += ch;
181
460
  w += cw;
461
+ i++;
182
462
  }
183
463
  return out;
184
464
  }
@@ -232,6 +512,8 @@ export class FormEditor<T extends Record<string, unknown>> {
232
512
  private onCancel: () => void;
233
513
  private cachedWidth = -1;
234
514
  private cachedLines: string[] = [];
515
+ /** 括包粘贴缓冲:pi 框架的 Ctrl+V / Alt+V / 右键粘贴会用 ESC[200~..ESC[201~ 包后 call handleInput。 */
516
+ private pasteBuf = new PasteBuffer();
235
517
 
236
518
  constructor(opts: {
237
519
  title: string;
@@ -250,6 +532,29 @@ export class FormEditor<T extends Record<string, unknown>> {
250
532
  this.onCancel = opts.onCancel;
251
533
  this.draft = this.currentValueAsString();
252
534
  this.draftIsOriginal = true;
535
+ // 粘贴:view 模式自动进 edit(避免被 view 模式接货的选中不动);仅 typeable 字段响应
536
+ this.pasteBuf.onPaste = (text) => {
537
+ if (!text) return;
538
+ const f0 = this.fields[this.cursor];
539
+ const isTypeable = f0 && (f0.type === "text" || f0.type === "secret" || f0.type === "number" || f0.type === "json");
540
+ if (!isTypeable) return;
541
+ if (!this.editing) {
542
+ // secret:跟 Enter 走同路径——草稿重置为原值,未修改标记
543
+ if (f0.type === "secret") {
544
+ const orig = (this.values as any)[f0.key];
545
+ this.draft = (orig === undefined || orig === null) ? "" : String(orig);
546
+ this.draftIsOriginal = true;
547
+ }
548
+ this.editing = true;
549
+ }
550
+ // number:过滤非数字,避免误粘 "123abc";secret / text / json 原样拼
551
+ const chunk = f0.type === "number" ? text.replace(/[^0-9]/g, "") : text;
552
+ if (!chunk) return;
553
+ if (f0.type === "number" && this.draftIsOriginal) this.draft = chunk; // 首次输入覆盖
554
+ else this.draft += chunk;
555
+ this.draftIsOriginal = false;
556
+ this.invalidate();
557
+ };
253
558
  }
254
559
 
255
560
  private currentValueAsString(): string {
@@ -367,6 +672,12 @@ export class FormEditor<T extends Record<string, unknown>> {
367
672
  }
368
673
 
369
674
  handleInput(data: string): void {
675
+ // 括包粘贴:Ctrl+V / Alt+V / 右键粘走 PasteBuffer(onPaste 检查 editing + typeable)
676
+ if (this.pasteBuf.feed(data)) {
677
+ const rest = this.pasteBuf.consumeRest();
678
+ if (rest) this.processRest(rest);
679
+ return;
680
+ }
370
681
  const f0 = this.fields[this.cursor];
371
682
  const isNonInput = f0?.type === "select" || f0?.type === "levelmap" || f0?.type === "multiselect";
372
683
  const isTypeable = f0 && (f0.type === "text" || f0.type === "secret" || f0.type === "number" || f0.type === "json");
@@ -541,6 +852,34 @@ export class FormEditor<T extends Record<string, unknown>> {
541
852
  }
542
853
  }
543
854
 
855
+ /** 处理括包粘贴后剩余的字符串:按字符逐个调 handleInput(不走 PasteBuffer 路径)。 */
856
+ private processRest(rest: string): void {
857
+ for (const ch of rest) this.dispatchChar(ch);
858
+ }
859
+
860
+ /** 单个字符的常规处理(仅 typeable 字符 / Backspace)。其他键不响应。 */
861
+ private dispatchChar(ch: string): void {
862
+ const f0 = this.fields[this.cursor];
863
+ const isTypeable = f0 && (f0.type === "text" || f0.type === "secret" || f0.type === "number" || f0.type === "json");
864
+ if (matchesKey(ch, "backspace")) {
865
+ if (!this.editing || !isTypeable) return;
866
+ if (this.draft.length > 0) this.draft = this.draft.slice(0, -1);
867
+ this.draftIsOriginal = false;
868
+ this.invalidate();
869
+ return;
870
+ }
871
+ if (!this.editing || !isTypeable) return;
872
+ if (ch.length === 1 && ch.charCodeAt(0) >= 32 && ch.charCodeAt(0) < 127) {
873
+ if (f0?.type === "number" && /^\d$/.test(ch) && this.draftIsOriginal) {
874
+ this.draft = ch;
875
+ } else {
876
+ this.draft += ch;
877
+ }
878
+ this.draftIsOriginal = false;
879
+ this.invalidate();
880
+ }
881
+ }
882
+
544
883
  invalidate(): void { this.cachedWidth = -1; this.cachedLines = []; }
545
884
 
546
885
  private formatValue(f: FormField, v: unknown): string {