@fanchaozz/provider-manager 0.2.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +257 -244
- package/README_EN.md +12 -0
- package/components.ts +192 -40
- package/forms.ts +700 -542
- package/package.json +47 -47
- package/sync.ts +3 -1
- package/ui.ts +20 -10
package/components.ts
CHANGED
|
@@ -45,28 +45,42 @@ export type ChecklistItem = {
|
|
|
45
45
|
disabled?: boolean; // true = 显示但不让选
|
|
46
46
|
};
|
|
47
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
|
+
|
|
48
55
|
export class ModelChecklist {
|
|
49
56
|
private items: ChecklistItem[];
|
|
50
57
|
private selected: Set<string>;
|
|
51
58
|
private cursor = 0;
|
|
59
|
+
private top = 0;
|
|
52
60
|
private title: string;
|
|
53
61
|
private theme: any;
|
|
62
|
+
private maxRows: number;
|
|
54
63
|
private onConfirm: (selected: string[]) => void;
|
|
55
64
|
private onCancel: () => void;
|
|
56
65
|
private cachedWidth = -1;
|
|
57
66
|
private cachedLines: string[] = [];
|
|
67
|
+
/** search 输入框的当前内容。同步起作用、可被全打印字符 / Backspace 修改。 */
|
|
68
|
+
private query = "";
|
|
58
69
|
|
|
59
70
|
constructor(opts: {
|
|
60
71
|
title: string;
|
|
61
72
|
items: ChecklistItem[];
|
|
62
73
|
theme: any;
|
|
63
74
|
preSelect?: (item: ChecklistItem) => boolean; // 默认全选时筛掉不想要的
|
|
75
|
+
maxRows?: number;
|
|
64
76
|
onConfirm: (selected: string[]) => void;
|
|
65
77
|
onCancel: () => void;
|
|
66
78
|
}) {
|
|
67
79
|
this.title = opts.title;
|
|
68
80
|
this.items = opts.items;
|
|
69
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));
|
|
70
84
|
this.onConfirm = opts.onConfirm;
|
|
71
85
|
this.onCancel = opts.onCancel;
|
|
72
86
|
this.selected = new Set();
|
|
@@ -77,6 +91,26 @@ export class ModelChecklist {
|
|
|
77
91
|
}
|
|
78
92
|
}
|
|
79
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
|
+
|
|
80
114
|
handleInput(data: string): void {
|
|
81
115
|
if (matchesKey(data, "escape") || data === "q") {
|
|
82
116
|
this.onCancel();
|
|
@@ -86,8 +120,28 @@ export class ModelChecklist {
|
|
|
86
120
|
this.onConfirm([...this.selected]);
|
|
87
121
|
return;
|
|
88
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
|
+
}
|
|
89
143
|
if (data === " ") {
|
|
90
|
-
const it =
|
|
144
|
+
const it = visible[this.cursor];
|
|
91
145
|
if (it && !it.disabled) {
|
|
92
146
|
if (this.selected.has(it.id)) this.selected.delete(it.id);
|
|
93
147
|
else this.selected.add(it.id);
|
|
@@ -95,32 +149,36 @@ export class ModelChecklist {
|
|
|
95
149
|
}
|
|
96
150
|
return;
|
|
97
151
|
}
|
|
152
|
+
// wrap-around:顶部 ↑ 跳到末项、底部 ↓ 跳回首项(和 pi 的 /models 一致)
|
|
98
153
|
if (matchesKey(data, "down") || data === "j") {
|
|
99
|
-
this.cursor =
|
|
100
|
-
this.invalidate();
|
|
101
|
-
} else if (matchesKey(data, "up") || data === "k") {
|
|
102
|
-
this.cursor = Math.max(this.cursor - 1, 0);
|
|
154
|
+
this.cursor = (this.cursor + 1) % visible.length;
|
|
103
155
|
this.invalidate();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
else this.selected = new Set(allIds);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (matchesKey(data, "up") || data === "k") {
|
|
159
|
+
this.cursor = (this.cursor - 1 + visible.length) % visible.length;
|
|
109
160
|
this.invalidate();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (!this.selected.has(it.id)) next.add(it.id);
|
|
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();
|
|
122
172
|
}
|
|
123
|
-
|
|
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;
|
|
124
182
|
this.invalidate();
|
|
125
183
|
}
|
|
126
184
|
}
|
|
@@ -132,53 +190,147 @@ export class ModelChecklist {
|
|
|
132
190
|
const th = this.theme;
|
|
133
191
|
const lines: string[] = [];
|
|
134
192
|
|
|
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
193
|
const total = this.items.length;
|
|
139
194
|
const sel = this.selected.size;
|
|
140
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 状态
|
|
141
203
|
const summary = ` ${sel}/${total} selected${disabled ? ` (${disabled} existing, skipped)` : ""} `;
|
|
142
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));
|
|
143
211
|
lines.push("");
|
|
144
212
|
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
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)"));
|
|
148
222
|
} else {
|
|
149
|
-
|
|
150
|
-
|
|
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];
|
|
151
245
|
const isCursor = i === this.cursor;
|
|
152
|
-
const arrow = isCursor ? th.fg("accent", "
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
else box = th.fg("dim", "[ ]");
|
|
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", "[ ]");
|
|
157
250
|
const id = isCursor ? th.bold(it.id) : it.id;
|
|
251
|
+
const topLabel = (i === 0 && !needPinOutside) ? th.fg("dim", " (top)") : "";
|
|
158
252
|
const sub = it.label ? " " + th.fg("muted", it.label) : "";
|
|
159
|
-
|
|
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} …)`;
|
|
160
258
|
}
|
|
161
259
|
}
|
|
162
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 置底
|
|
163
272
|
lines.push("");
|
|
164
273
|
lines.push(th.fg("borderMuted", "─".repeat(width)));
|
|
165
|
-
|
|
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
|
+
|
|
166
277
|
this.cachedWidth = width;
|
|
167
278
|
this.cachedLines = lines;
|
|
168
279
|
return lines;
|
|
169
280
|
}
|
|
170
281
|
}
|
|
171
282
|
|
|
172
|
-
/** 给 render
|
|
283
|
+
/** 给 render 用的截断。计算宽度时跳过 ANSI 转义序列 + 已知主题标签,避免裁到一半丢颜色。 */
|
|
173
284
|
function truncateForRender(s: string, width: number): string {
|
|
174
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
|
+
]);
|
|
175
296
|
let w = 0;
|
|
176
297
|
let out = "";
|
|
177
|
-
|
|
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]!;
|
|
178
329
|
const cw = isWideChar(ch) ? 2 : 1;
|
|
179
330
|
if (w + cw > width) return out + (w + 1 <= width ? "…" : "");
|
|
180
331
|
out += ch;
|
|
181
332
|
w += cw;
|
|
333
|
+
i++;
|
|
182
334
|
}
|
|
183
335
|
return out;
|
|
184
336
|
}
|