@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/README.md +257 -244
- package/README_EN.md +12 -0
- package/components.ts +379 -40
- package/forms.ts +700 -542
- package/package.json +1 -1
- package/sync.ts +3 -1
- package/ui.ts +8 -4
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 =
|
|
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 =
|
|
242
|
+
this.cursor = (this.cursor + 1) % visible.length;
|
|
100
243
|
this.invalidate();
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|