pi-cliproxyapi 0.1.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 +101 -0
- package/index.ts +67 -0
- package/package.json +37 -0
- package/src/apply.ts +209 -0
- package/src/commands.ts +247 -0
- package/src/compat.ts +128 -0
- package/src/config.ts +199 -0
- package/src/conflicts.ts +66 -0
- package/src/fetch-models.ts +335 -0
- package/src/fetch-usage.ts +103 -0
- package/src/log.ts +19 -0
- package/src/ui-overlay.ts +292 -0
- package/src/ui-picker.ts +842 -0
- package/src/ui-setup.ts +289 -0
- package/src/ui-usage.ts +191 -0
package/src/ui-picker.ts
ADDED
|
@@ -0,0 +1,842 @@
|
|
|
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
|
+
}
|