@fanchaozz/provider-manager 0.2.3 → 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/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 = this.items[this.cursor];
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 = Math.min(this.cursor + 1, this.items.length - 1);
100
- this.invalidate();
101
- } else if (matchesKey(data, "up") || data === "k") {
102
- this.cursor = Math.max(this.cursor - 1, 0);
154
+ this.cursor = (this.cursor + 1) % visible.length;
103
155
  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);
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
- } 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);
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
- this.selected = next;
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
- // List
146
- if (total === 0) {
147
- lines.push(th.fg("dim", " (no models to choose from)"));
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
- for (let i = 0; i < this.items.length; i++) {
150
- const it = this.items[i];
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
- 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", "[ ]");
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
- lines.push(truncateForRender(`${arrow}${box} ${id}${sub}`, width));
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
- lines.push(th.fg("dim", " Space toggle · ↑↓/jk nav · g/G top/bot · a all · i invert · Enter apply · Esc cancel"));
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 用的截断(不剥主题,简化版;与 ui.ts truncateToWidth 行为一致) */
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
- for (const ch of s) {
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
  }