pi-cliproxyapi 0.1.1 → 0.2.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/src/ui-picker.ts DELETED
@@ -1,842 +0,0 @@
1
- // Overlay picker for /cliproxy.
2
- //
3
- // Layout:
4
- // Built-in providers
5
- // - <name> N/total ▾
6
- // [x] model-id · subtitle
7
- // ...
8
- // Custom providers
9
- // - <slug> N models ▾
10
- // ── assigned ──
11
- // [x] model-id · suggested:<origin>
12
- // ── available (not in another group) ──
13
- // suggested:<origin>
14
- // [ ] model-id
15
- // ...
16
- // + New custom provider…
17
- // Save & apply
18
- // Cancel
19
- //
20
- // Model exclusivity: a model assigned to a custom provider is REMOVED from
21
- // the "available" section of every other custom provider. Built-in
22
- // providers are independent of this exclusivity (they are routed to native
23
- // Pi providers, not into the same pool).
24
-
25
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
26
- import { getModels, getProviders } from "@earendil-works/pi-ai";
27
- import type { Api } from "@earendil-works/pi-ai";
28
- import {
29
- type Component,
30
- getKeybindings,
31
- Input,
32
- matchesKey,
33
- visibleWidth,
34
- } from "@earendil-works/pi-tui";
35
-
36
- import { withProviderPrefix } from "./compat.ts";
37
- import type { CustomProviderModelConfig, ProxyConfig } from "./config.ts";
38
- import type { Discovery, DiscoveryCustomEntry } from "./fetch-models.ts";
39
-
40
- interface Theme {
41
- fg(name: string, s: string): string;
42
- bold(s: string): string;
43
- }
44
-
45
- interface OverlayTui {
46
- requestRender(): void;
47
- rows?: number;
48
- cols?: number;
49
- }
50
-
51
- // --------------------------------------------------------------------------- public entry
52
-
53
- export async function runPicker(
54
- ctx: ExtensionCommandContext,
55
- cfg: ProxyConfig,
56
- discovery: Discovery,
57
- opts: { readOnly?: boolean; title?: string } = {},
58
- ): Promise<ProxyConfig | null> {
59
- if (!ctx.hasUI) {
60
- ctx.ui.notify("interactive UI required for /cliproxy", "warning");
61
- return null;
62
- }
63
- const draft: ProxyConfig = structuredClone(cfg);
64
-
65
- return ctx.ui.custom<ProxyConfig | null>(
66
- (tui, theme, _kb, done) =>
67
- buildPicker(
68
- tui as unknown as OverlayTui,
69
- theme as unknown as Theme,
70
- draft,
71
- discovery,
72
- ctx,
73
- opts,
74
- done,
75
- ),
76
- {
77
- overlay: true,
78
- overlayOptions: { width: 140, maxHeight: "92%" },
79
- },
80
- );
81
- }
82
-
83
- // --------------------------------------------------------------------------- row model
84
-
85
- type Row =
86
- | { kind: "section"; id: string; label: string }
87
- | {
88
- kind: "provider";
89
- id: string;
90
- providerKind: "builtin" | "custom";
91
- providerName: string;
92
- label: string;
93
- subtitle?: string;
94
- selectedCount: number;
95
- totalCount: number;
96
- expanded: boolean;
97
- }
98
- | { kind: "subheader"; id: string; label: string }
99
- | {
100
- kind: "model";
101
- id: string;
102
- providerKind: "builtin" | "custom";
103
- providerName: string;
104
- modelId: string;
105
- label: string;
106
- subtitle?: string;
107
- checked: boolean;
108
- }
109
- | {
110
- kind: "action";
111
- id: string;
112
- label: string;
113
- action: "save" | "cancel" | "new-custom";
114
- };
115
-
116
- // --------------------------------------------------------------------------- picker
117
-
118
- function buildPicker(
119
- tui: OverlayTui,
120
- theme: Theme,
121
- cfg: ProxyConfig,
122
- discovery: Discovery,
123
- ctx: ExtensionCommandContext,
124
- opts: { readOnly?: boolean; title?: string },
125
- done: (v: ProxyConfig | null) => void,
126
- ): Component & { handleInput(data: string): void } {
127
- const readOnly = opts.readOnly === true;
128
- const expanded = new Set<string>();
129
- const builtinCandidates = collectBuiltinCandidates(discovery);
130
-
131
- // Auto-expand providers that already have selections.
132
- for (const name of Object.keys(cfg.builtinProviders)) {
133
- if ((cfg.builtinProviders[name]?.models?.length ?? 0) > 0) {
134
- expanded.add(`builtin:${name}`);
135
- }
136
- }
137
- for (const name of Object.keys(cfg.customProviders)) {
138
- expanded.add(`custom:${name}`);
139
- }
140
-
141
- let cursorRowId: string | null = null;
142
- let scrollOffset = 0;
143
- let lastRenderHeight = 20;
144
-
145
- // ----- compute: which custom-pool models are claimed by which provider
146
- const claimedBy = (): Map<string, string> => {
147
- const m = new Map<string, string>();
148
- for (const [slug, p] of Object.entries(cfg.customProviders)) {
149
- for (const mm of p.models) m.set(mm.id, slug);
150
- }
151
- return m;
152
- };
153
-
154
- const customPoolById = new Map(discovery.customPool.map((m) => [m.id, m]));
155
-
156
- const rebuildRows = (): Row[] => {
157
- const rows: Row[] = [];
158
- const claim = claimedBy();
159
-
160
- // ── Built-in providers ───────────────────────────────────────────────
161
- rows.push({
162
- kind: "section",
163
- id: "sec:builtin",
164
- label: "Built-in providers",
165
- });
166
- if (builtinCandidates.length === 0) {
167
- rows.push({
168
- kind: "subheader",
169
- id: "sub:no-builtin",
170
- label: "(no overlap with proxy model list)",
171
- });
172
- }
173
- for (const c of builtinCandidates) {
174
- const cfgEntry = cfg.builtinProviders[c.name];
175
- const selected = new Set(cfgEntry?.models ?? []);
176
- const selectedCount = c.models.filter((m) => selected.has(m.id)).length;
177
- const provId = `builtin:${c.name}`;
178
- rows.push({
179
- kind: "provider",
180
- id: provId,
181
- providerKind: "builtin",
182
- providerName: c.name,
183
- label: c.name,
184
- subtitle: c.api,
185
- selectedCount,
186
- totalCount: c.models.length,
187
- expanded: expanded.has(provId),
188
- });
189
- if (expanded.has(provId)) {
190
- for (const m of c.models) {
191
- rows.push({
192
- kind: "model",
193
- id: `${provId}:${m.id}`,
194
- providerKind: "builtin",
195
- providerName: c.name,
196
- modelId: m.id,
197
- label: m.id,
198
- subtitle: m.name && m.name !== m.id ? m.name : undefined,
199
- checked: selected.has(m.id),
200
- });
201
- }
202
- }
203
- }
204
-
205
- // ── Custom providers ─────────────────────────────────────────────────
206
- rows.push({ kind: "section", id: "sec:custom", label: "Custom providers" });
207
-
208
- const customNames = Object.keys(cfg.customProviders).sort();
209
- if (customNames.length === 0) {
210
- rows.push({
211
- kind: "subheader",
212
- id: "sub:no-custom",
213
- label:
214
- "(none yet \u2014 create one with \u201c+ New custom provider\u2026\u201d below)",
215
- });
216
- }
217
- for (const slug of customNames) {
218
- const p = cfg.customProviders[slug]!;
219
- const provId = `custom:${slug}`;
220
- const isExpanded = expanded.has(provId);
221
- rows.push({
222
- kind: "provider",
223
- id: provId,
224
- providerKind: "custom",
225
- providerName: slug,
226
- label: slug,
227
- subtitle: `${p.api} \u00b7 ${p.models.length} model${p.models.length === 1 ? "" : "s"}`,
228
- selectedCount: p.models.length,
229
- totalCount: p.models.length,
230
- expanded: isExpanded,
231
- });
232
- if (!isExpanded) continue;
233
-
234
- // assigned models
235
- if (p.models.length > 0) {
236
- rows.push({
237
- kind: "subheader",
238
- id: `${provId}:sub:assigned`,
239
- label: "assigned",
240
- });
241
- for (const m of p.models) {
242
- const src = customPoolById.get(m.id);
243
- const origin = src?.suggestedProvider;
244
- const subtitle = src
245
- ? origin && origin !== slug
246
- ? `suggested: ${origin} \u00b7 owned_by=${src.ownedBy}`
247
- : `owned_by=${src.ownedBy}`
248
- : "(not present on proxy right now)";
249
- rows.push({
250
- kind: "model",
251
- id: `${provId}:${m.id}`,
252
- providerKind: "custom",
253
- providerName: slug,
254
- modelId: m.id,
255
- label: m.id,
256
- subtitle,
257
- checked: true,
258
- });
259
- }
260
- }
261
-
262
- // available models, grouped by suggested origin (= server hint).
263
- // A model is "available" here if it is not claimed by ANY custom provider yet.
264
- const available = discovery.customPool.filter((m) => !claim.has(m.id));
265
- if (available.length > 0) {
266
- rows.push({
267
- kind: "subheader",
268
- id: `${provId}:sub:available`,
269
- label: "available (not in another group)",
270
- });
271
- const groups = new Map<string, DiscoveryCustomEntry[]>();
272
- for (const m of available) {
273
- const key = m.suggestedProvider || "misc";
274
- const arr = groups.get(key) ?? [];
275
- arr.push(m);
276
- groups.set(key, arr);
277
- }
278
- for (const [origin, list] of Array.from(groups.entries()).sort()) {
279
- rows.push({
280
- kind: "subheader",
281
- id: `${provId}:sub:origin:${origin}`,
282
- label: `${origin}`,
283
- });
284
- for (const m of list) {
285
- rows.push({
286
- kind: "model",
287
- id: `${provId}:add:${m.id}`,
288
- providerKind: "custom",
289
- providerName: slug,
290
- modelId: m.id,
291
- label: m.id,
292
- subtitle: `${m.name} \u00b7 owned_by=${m.ownedBy}`,
293
- checked: false,
294
- });
295
- }
296
- }
297
- } else if (p.models.length === 0) {
298
- rows.push({
299
- kind: "subheader",
300
- id: `${provId}:sub:empty`,
301
- label: "(no available models left in the pool)",
302
- });
303
- }
304
- }
305
-
306
- if (!readOnly) {
307
- rows.push({
308
- kind: "action",
309
- id: "act:new-custom",
310
- label: "+ New custom provider\u2026",
311
- action: "new-custom",
312
- });
313
- }
314
-
315
- // ── Actions ──────────────────────────────────────────────────────────
316
- rows.push({ kind: "section", id: "sec:actions", label: "" });
317
- if (readOnly) {
318
- rows.push({
319
- kind: "action",
320
- id: "act:close",
321
- label: "Close",
322
- action: "cancel",
323
- });
324
- } else {
325
- rows.push({
326
- kind: "action",
327
- id: "act:save",
328
- label: "Save & apply",
329
- action: "save",
330
- });
331
- rows.push({
332
- kind: "action",
333
- id: "act:cancel",
334
- label: "Cancel (discard changes)",
335
- action: "cancel",
336
- });
337
- }
338
- return rows;
339
- };
340
-
341
- let rows = rebuildRows();
342
- const firstSelectable = rows.findIndex(isSelectable);
343
- cursorRowId = firstSelectable >= 0 ? rows[firstSelectable]!.id : null;
344
-
345
- const indexOfCursor = (): number => {
346
- if (!cursorRowId) return 0;
347
- const idx = rows.findIndex((r) => r.id === cursorRowId);
348
- return idx >= 0 ? idx : 0;
349
- };
350
-
351
- const moveCursor = (delta: number): void => {
352
- let idx = indexOfCursor();
353
- const dir = delta > 0 ? 1 : -1;
354
- let steps = Math.abs(delta);
355
- while (steps > 0) {
356
- idx += dir;
357
- if (idx < 0 || idx >= rows.length) {
358
- idx -= dir;
359
- break;
360
- }
361
- if (isSelectable(rows[idx]!)) steps--;
362
- }
363
- cursorRowId = rows[idx]!.id;
364
- };
365
-
366
- const ensureCursorVisible = (height: number): void => {
367
- const visible = Math.max(1, height - 2);
368
- const idx = indexOfCursor();
369
- if (idx < scrollOffset) scrollOffset = idx;
370
- else if (idx >= scrollOffset + visible) scrollOffset = idx - visible + 1;
371
- const max = Math.max(0, rows.length - visible);
372
- if (scrollOffset > max) scrollOffset = max;
373
- if (scrollOffset < 0) scrollOffset = 0;
374
- };
375
-
376
- const onSpace = (): void => {
377
- if (readOnly) return;
378
- const r = rows[indexOfCursor()];
379
- if (!r) return;
380
- if (r.kind === "model") {
381
- toggleModel(cfg, customPoolById, r);
382
- rows = rebuildRows();
383
- // after toggle the row id may move (assigned -> available section
384
- // changes the id from `${provId}:add:${modelId}` to `${provId}:${modelId}`),
385
- // so anchor to the closest still-existing row of the same model.
386
- const candidates = [
387
- `${r.providerKind}:${r.providerName}:${r.modelId}`,
388
- `${r.providerKind}:${r.providerName}:add:${r.modelId}`,
389
- ];
390
- for (const id of candidates) {
391
- if (rows.some((row) => row.id === id)) {
392
- cursorRowId = id;
393
- break;
394
- }
395
- }
396
- } else if (r.kind === "provider") {
397
- // space on a provider header — built-in: toggle ALL; custom: ignore
398
- // (we want explicit per-model control for custom groups so the
399
- // "available pool" stays predictable).
400
- if (r.providerKind === "builtin") {
401
- toggleBuiltinAll(cfg, r, builtinCandidates);
402
- rows = rebuildRows();
403
- cursorRowId = r.id;
404
- }
405
- }
406
- };
407
-
408
- const onEnter = async (): Promise<void> => {
409
- const r = rows[indexOfCursor()];
410
- if (!r) return;
411
- if (r.kind === "provider") {
412
- const id = r.id;
413
- if (expanded.has(id)) expanded.delete(id);
414
- else expanded.add(id);
415
- rows = rebuildRows();
416
- cursorRowId = id;
417
- } else if (r.kind === "action") {
418
- if (r.action === "save") {
419
- done(cfg);
420
- return;
421
- }
422
- if (r.action === "cancel") {
423
- done(null);
424
- return;
425
- }
426
- if (r.action === "new-custom") {
427
- const name = await promptNewProviderName(
428
- ctx,
429
- cfg.proxy.providerPrefix,
430
- );
431
- if (name) {
432
- if (!cfg.customProviders[name]) {
433
- cfg.customProviders[name] = {
434
- api: "openai-completions",
435
- models: [],
436
- };
437
- }
438
- expanded.add(`custom:${name}`);
439
- rows = rebuildRows();
440
- cursorRowId = `custom:${name}`;
441
- }
442
- tui.requestRender();
443
- return;
444
- }
445
- } else if (r.kind === "model") {
446
- onSpace();
447
- }
448
- };
449
-
450
- const onDelete = (): void => {
451
- if (readOnly) return;
452
- const r = rows[indexOfCursor()];
453
- if (!r || r.kind !== "provider" || r.providerKind !== "custom") return;
454
- delete cfg.customProviders[r.providerName];
455
- expanded.delete(r.id);
456
- rows = rebuildRows();
457
- const firstIdx = rows.findIndex(isSelectable);
458
- cursorRowId = firstIdx >= 0 ? rows[firstIdx]!.id : null;
459
- };
460
-
461
- return {
462
- render(width: number): string[] {
463
- lastRenderHeight = Math.max(
464
- 10,
465
- Math.min(rows.length + 2, (tui.rows ?? 40) - 6),
466
- );
467
- ensureCursorVisible(lastRenderHeight);
468
- const inner = Math.max(40, width - 2);
469
- const visible = Math.max(1, lastRenderHeight - 2);
470
- const slice = rows.slice(scrollOffset, scrollOffset + visible);
471
- const cursorIdx = indexOfCursor();
472
-
473
- const title =
474
- opts.title ?? (readOnly ? " /cliproxy-list " : " /cliproxy ");
475
- const titleBar = theme.fg(
476
- "borderAccent",
477
- `\u256d\u2500 ${theme.bold(theme.fg("accent", title))}${"\u2500".repeat(Math.max(0, inner - visibleWidth(title) - 4))}\u256e`,
478
- );
479
- const hint = readOnly
480
- ? " \u2191\u2193 navigate \u21b5 expand q/esc close "
481
- : " \u2191\u2193 navigate \u21b5 expand/save space toggle delete remove group q/esc cancel ";
482
- const counter = ` ${cursorIdx + 1}/${rows.length} `;
483
- const fill = "\u2500".repeat(
484
- Math.max(0, inner - visibleWidth(hint) - visibleWidth(counter) - 4),
485
- );
486
- const footerBar = theme.fg(
487
- "borderAccent",
488
- `\u2570\u2500${theme.fg("dim", hint)}${fill}${theme.fg("muted", counter)}\u2500\u256f`,
489
- );
490
- const side = theme.fg("borderAccent", "\u2502");
491
- const out: string[] = [titleBar];
492
- for (let i = 0; i < slice.length; i++) {
493
- const row = slice[i]!;
494
- const abs = scrollOffset + i;
495
- const isCursor = abs === cursorIdx;
496
- out.push(
497
- `${side} ${pad(renderRow(theme, row, isCursor), inner - 2)} ${side}`,
498
- );
499
- }
500
- while (out.length < visible + 1) {
501
- out.push(`${side} ${pad("", inner - 2)} ${side}`);
502
- }
503
- out.push(footerBar);
504
- return out;
505
- },
506
- invalidate(): void {
507
- /* stateless */
508
- },
509
- handleInput(data: string): void {
510
- const kb = getKeybindings();
511
- if (
512
- kb.matches(data, "tui.select.cancel") ||
513
- matchesKey(data, "q") ||
514
- matchesKey(data, "shift+q")
515
- ) {
516
- done(null);
517
- return;
518
- }
519
- if (matchesKey(data, "enter") || matchesKey(data, "return")) {
520
- void onEnter().then(() => tui.requestRender());
521
- return;
522
- }
523
- if (matchesKey(data, "space")) {
524
- onSpace();
525
- tui.requestRender();
526
- return;
527
- }
528
- if (matchesKey(data, "delete") || matchesKey(data, "backspace")) {
529
- onDelete();
530
- tui.requestRender();
531
- return;
532
- }
533
- if (matchesKey(data, "up") || matchesKey(data, "k")) {
534
- moveCursor(-1);
535
- tui.requestRender();
536
- return;
537
- }
538
- if (matchesKey(data, "down") || matchesKey(data, "j")) {
539
- moveCursor(1);
540
- tui.requestRender();
541
- return;
542
- }
543
- if (matchesKey(data, "pageUp") || matchesKey(data, "b")) {
544
- moveCursor(-Math.max(1, lastRenderHeight - 3));
545
- tui.requestRender();
546
- return;
547
- }
548
- if (matchesKey(data, "pageDown") || matchesKey(data, "f")) {
549
- moveCursor(Math.max(1, lastRenderHeight - 3));
550
- tui.requestRender();
551
- return;
552
- }
553
- if (matchesKey(data, "home") || matchesKey(data, "g")) {
554
- const idx = rows.findIndex(isSelectable);
555
- if (idx >= 0) cursorRowId = rows[idx]!.id;
556
- tui.requestRender();
557
- return;
558
- }
559
- if (matchesKey(data, "end") || matchesKey(data, "shift+g")) {
560
- for (let i = rows.length - 1; i >= 0; i--) {
561
- if (isSelectable(rows[i]!)) {
562
- cursorRowId = rows[i]!.id;
563
- break;
564
- }
565
- }
566
- tui.requestRender();
567
- return;
568
- }
569
- if (matchesKey(data, "right") || matchesKey(data, "l")) {
570
- const r = rows[indexOfCursor()];
571
- if (r?.kind === "provider" && !expanded.has(r.id)) {
572
- expanded.add(r.id);
573
- rows = rebuildRows();
574
- cursorRowId = r.id;
575
- tui.requestRender();
576
- }
577
- return;
578
- }
579
- if (matchesKey(data, "left") || matchesKey(data, "h")) {
580
- const r = rows[indexOfCursor()];
581
- if (r?.kind === "provider" && expanded.has(r.id)) {
582
- expanded.delete(r.id);
583
- rows = rebuildRows();
584
- cursorRowId = r.id;
585
- tui.requestRender();
586
- }
587
- return;
588
- }
589
- },
590
- };
591
- }
592
-
593
- function isSelectable(r: Row): boolean {
594
- return r.kind === "provider" || r.kind === "model" || r.kind === "action";
595
- }
596
-
597
- // --------------------------------------------------------------------------- row rendering
598
-
599
- function renderRow(theme: Theme, row: Row, isCursor: boolean): string {
600
- const cur = isCursor ? theme.fg("accent", "\u25b6 ") : " ";
601
- if (row.kind === "section") {
602
- return theme.bold(theme.fg("accent", `\u2501 ${row.label} `));
603
- }
604
- if (row.kind === "subheader") {
605
- return ` ${theme.fg("muted", `\u00b7 ${row.label}`)}`;
606
- }
607
- if (row.kind === "provider") {
608
- const arrow = row.expanded ? "\u25be" : "\u25b8";
609
- const counts = `${row.selectedCount}/${row.totalCount}`;
610
- const stats =
611
- row.selectedCount > 0
612
- ? theme.fg("success", `\u25cf ${counts}`)
613
- : theme.fg("dim", `\u25cb ${counts}`);
614
- const name = isCursor
615
- ? theme.bold(theme.fg("accent", row.label))
616
- : theme.bold(row.label);
617
- const sub = row.subtitle ? ` ${theme.fg("dim", row.subtitle)}` : "";
618
- return `${cur}${arrow} ${name} ${stats}${sub}`;
619
- }
620
- if (row.kind === "model") {
621
- const box = row.checked
622
- ? theme.fg("success", "[\u2713]")
623
- : theme.fg("dim", "[ ]");
624
- const idStr = isCursor ? theme.fg("accent", row.label) : row.label;
625
- const sub = row.subtitle ? ` ${theme.fg("dim", row.subtitle)}` : "";
626
- return `${cur} ${box} ${idStr}${sub}`;
627
- }
628
- const icon =
629
- row.action === "save"
630
- ? theme.fg("success", "\u2714")
631
- : row.action === "new-custom"
632
- ? theme.fg("accent", "\u002b")
633
- : theme.fg("error", "\u2716");
634
- const label = isCursor
635
- ? theme.bold(theme.fg("accent", row.label))
636
- : theme.fg("muted", row.label);
637
- return `${cur}${icon} ${label}`;
638
- }
639
-
640
- function pad(s: string, width: number): string {
641
- const w = visibleWidth(s);
642
- if (w >= width) return s;
643
- return s + " ".repeat(width - w);
644
- }
645
-
646
- // --------------------------------------------------------------------------- mutation
647
-
648
- function toggleModel(
649
- cfg: ProxyConfig,
650
- customPoolById: Map<string, DiscoveryCustomEntry>,
651
- row: Extract<Row, { kind: "model" }>,
652
- ): void {
653
- if (row.providerKind === "builtin") {
654
- const cur = cfg.builtinProviders[row.providerName] ?? {
655
- enabled: true,
656
- models: [],
657
- };
658
- const selected = new Set(cur.models);
659
- if (selected.has(row.modelId)) selected.delete(row.modelId);
660
- else selected.add(row.modelId);
661
- cfg.builtinProviders[row.providerName] = {
662
- enabled: selected.size > 0,
663
- apiOverride: cur.apiOverride ?? null,
664
- models: Array.from(selected).sort(),
665
- };
666
- return;
667
- }
668
- // custom: toggle membership in this slug.
669
- const slug = row.providerName;
670
- const cur = cfg.customProviders[slug] ?? {
671
- api: customPoolById.get(row.modelId)?.api ?? "openai-completions",
672
- models: [] as CustomProviderModelConfig[],
673
- };
674
- const idx = cur.models.findIndex((m) => m.id === row.modelId);
675
- if (idx >= 0) {
676
- cur.models.splice(idx, 1);
677
- } else {
678
- const src = customPoolById.get(row.modelId);
679
- cur.models.push(
680
- src
681
- ? {
682
- id: src.id,
683
- name: src.name,
684
- contextWindow: src.contextWindow,
685
- maxTokens: src.maxTokens,
686
- reasoning: src.reasoning,
687
- cost: src.cost,
688
- }
689
- : { id: row.modelId },
690
- );
691
- }
692
- // Keep group even if empty so user can add models later; only delete when
693
- // user explicitly removes the group (delete key on header).
694
- cfg.customProviders[slug] = cur;
695
- }
696
-
697
- function toggleBuiltinAll(
698
- cfg: ProxyConfig,
699
- row: Extract<Row, { kind: "provider" }>,
700
- builtinCandidates: ReturnType<typeof collectBuiltinCandidates>,
701
- ): void {
702
- const cand = builtinCandidates.find((c) => c.name === row.providerName);
703
- if (!cand) return;
704
- const all = cand.models.map((m) => m.id);
705
- const cur = cfg.builtinProviders[row.providerName];
706
- const allOn = cur && cur.models.length === all.length;
707
- cfg.builtinProviders[row.providerName] = {
708
- enabled: !allOn,
709
- apiOverride: cur?.apiOverride ?? null,
710
- models: allOn ? [] : all.sort(),
711
- };
712
- }
713
-
714
- // --------------------------------------------------------------------------- discovery aggregation
715
-
716
- interface BuiltinCandidate {
717
- name: string;
718
- api: Api;
719
- models: Array<{ id: string; name: string }>;
720
- }
721
-
722
- function collectBuiltinCandidates(discovery: Discovery): BuiltinCandidate[] {
723
- const proxyIds = new Set<string>();
724
- for (const p of discovery.builtinProviders)
725
- for (const m of p.models) proxyIds.add(m.id);
726
- for (const m of discovery.customPool) proxyIds.add(m.id);
727
-
728
- const out: BuiltinCandidate[] = [];
729
- for (const name of getProviders()) {
730
- try {
731
- const models = getModels(name as Parameters<typeof getModels>[0]);
732
- const matched = models.filter((m) => proxyIds.has(m.id));
733
- if (matched.length === 0) continue;
734
- out.push({
735
- name,
736
- api: matched[0]!.api,
737
- models: matched.map((m) => ({ id: m.id, name: m.name })),
738
- });
739
- } catch {
740
- /* ignore */
741
- }
742
- }
743
- out.sort((a, b) => a.name.localeCompare(b.name));
744
- return out;
745
- }
746
-
747
- // --------------------------------------------------------------------------- new provider prompt
748
-
749
- async function promptNewProviderName(
750
- ctx: ExtensionCommandContext,
751
- prefix: string | undefined,
752
- ): Promise<string | null> {
753
- const suggestion = withProviderPrefix(prefix, "group");
754
- return ctx.ui.custom<string | null>(
755
- (tui, theme, _kb, done) =>
756
- buildNamePrompt(
757
- tui as unknown as { requestRender(): void },
758
- theme as unknown as Theme,
759
- suggestion,
760
- done,
761
- ),
762
- {
763
- overlay: true,
764
- overlayOptions: { width: 80, maxHeight: "40%" },
765
- },
766
- );
767
- }
768
-
769
- function buildNamePrompt(
770
- tui: { requestRender(): void },
771
- theme: Theme,
772
- suggestion: string,
773
- done: (v: string | null) => void,
774
- ): Component & { handleInput(data: string): void } {
775
- const input = new Input();
776
- // Don't pre-fill if we have no prefix suggestion — force the user to type.
777
- if (suggestion) input.setValue(suggestion);
778
- input.focused = true;
779
- let error: string | null = null;
780
-
781
- input.onSubmit = (raw) => {
782
- const v = raw.trim();
783
- if (!/^[a-z0-9][a-z0-9._-]*$/i.test(v)) {
784
- error = "letters / digits / dot / dash / underscore only";
785
- tui.requestRender();
786
- return;
787
- }
788
- done(v);
789
- };
790
- input.onEscape = () => done(null);
791
-
792
- return {
793
- render(width: number): string[] {
794
- const inner = Math.max(40, width - 2);
795
- const titleBar = theme.fg(
796
- "borderAccent",
797
- `\u256d\u2500 ${theme.bold(theme.fg("accent", " new custom provider "))}${"\u2500".repeat(Math.max(0, inner - 24))}\u256e`,
798
- );
799
- const hint = theme.fg(
800
- "dim",
801
- suggestion
802
- ? `name shown in /model picker, e.g. ${suggestion}, ${suggestion.replace(/group$/, "tools")}`
803
- : "name shown in /model picker (letters / digits / dot / dash / underscore)",
804
- );
805
- const side = theme.fg("borderAccent", "\u2502");
806
- const errLine = error
807
- ? pad(theme.fg("error", `! ${error}`), inner - 2)
808
- : pad(
809
- theme.fg("dim", "enter = create \u00b7 esc = cancel"),
810
- inner - 2,
811
- );
812
- const inputLines = input.render(inner - 4);
813
- const out: string[] = [titleBar];
814
- out.push(`${side} ${pad(hint, inner - 2)} ${side}`);
815
- out.push(`${side} ${pad("", inner - 2)} ${side}`);
816
- for (const ln of inputLines) {
817
- out.push(
818
- `${side} ${pad(theme.fg("accent", `> ${ln}`), inner - 2)} ${side}`,
819
- );
820
- }
821
- out.push(`${side} ${pad("", inner - 2)} ${side}`);
822
- out.push(`${side} ${errLine} ${side}`);
823
- out.push(
824
- theme.fg("borderAccent", `\u2570${"\u2500".repeat(inner)}\u256f`),
825
- );
826
- return out;
827
- },
828
- invalidate(): void {
829
- input.invalidate();
830
- },
831
- handleInput(data: string): void {
832
- const kb = getKeybindings();
833
- if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
834
- done(null);
835
- return;
836
- }
837
- error = null;
838
- input.handleInput(data);
839
- tui.requestRender();
840
- },
841
- };
842
- }