pi-cliproxyapi 0.1.2 → 0.3.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 +48 -16
- package/index.ts +3 -3
- package/package.json +1 -1
- package/src/apply.ts +85 -10
- package/src/commands.ts +12 -170
- package/src/fetch-models.ts +2 -5
- package/src/log.ts +17 -4
- package/src/ui-frame.ts +118 -0
- package/src/ui-hub/hub.ts +264 -0
- package/src/ui-hub/index.ts +50 -0
- package/src/ui-hub/shell.ts +119 -0
- package/src/ui-hub/types.ts +16 -0
- package/src/ui-hub/view-diagnostics.ts +108 -0
- package/src/ui-hub/view-models.ts +515 -0
- package/src/ui-hub/view-usage.ts +131 -0
- package/src/ui-picker/catalog.ts +52 -0
- package/src/ui-picker/mutate.ts +167 -0
- package/src/ui-picker/prompt-confirm.ts +71 -0
- package/src/ui-picker/prompt-name.ts +90 -0
- package/src/ui-picker/providers.ts +68 -0
- package/src/ui-picker/render-text.ts +39 -0
- package/src/ui-picker/rows.ts +151 -0
- package/src/ui-picker/types.ts +56 -0
- package/src/ui-setup.ts +21 -48
- package/src/ui-usage.ts +1 -1
- package/src/ui-overlay.ts +0 -292
- package/src/ui-picker.ts +0 -842
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
// Models view — the three-panel picker, rewritten as a body-only hub view.
|
|
2
|
+
//
|
|
3
|
+
// \u2502 providers \u2502 assigned to <prov> \u2502
|
|
4
|
+
// \u2502 list \u2026 \u251c\u2500 available pool \u2500\u2500\u2524
|
|
5
|
+
// \u2502 \u2502 grouped by owned_by \u2502
|
|
6
|
+
//
|
|
7
|
+
// Bug fix vs. the old picker: navigation + activation index the pool through a
|
|
8
|
+
// SINGLE ordering (poolDisplayOrder = flatten(groupPoolByOwnedBy(poolFor))),
|
|
9
|
+
// the exact order the renderer iterates. The highlighted row therefore always
|
|
10
|
+
// maps to the model that gets toggled.
|
|
11
|
+
|
|
12
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
14
|
+
|
|
15
|
+
import type { ProxyConfig } from "../config.ts";
|
|
16
|
+
import type { Discovery } from "../fetch-models.ts";
|
|
17
|
+
import { buildCatalog } from "../ui-picker/catalog.ts";
|
|
18
|
+
import {
|
|
19
|
+
apiCompatible,
|
|
20
|
+
assignedIdsFor,
|
|
21
|
+
attachModel,
|
|
22
|
+
detachModel,
|
|
23
|
+
filterModelIds,
|
|
24
|
+
groupPoolByOwnedBy,
|
|
25
|
+
poolFor,
|
|
26
|
+
} from "../ui-picker/mutate.ts";
|
|
27
|
+
import { collectProviders } from "../ui-picker/providers.ts";
|
|
28
|
+
import { confirmRemoveProvider } from "../ui-picker/prompt-confirm.ts";
|
|
29
|
+
import { promptNewProviderName } from "../ui-picker/prompt-name.ts";
|
|
30
|
+
import { pad } from "../ui-picker/render-text.ts";
|
|
31
|
+
import {
|
|
32
|
+
renderEmpty,
|
|
33
|
+
renderModelRow,
|
|
34
|
+
renderNewProviderRow,
|
|
35
|
+
renderPanelHeader,
|
|
36
|
+
renderProviderRow,
|
|
37
|
+
renderSubheader,
|
|
38
|
+
} from "../ui-picker/rows.ts";
|
|
39
|
+
import type {
|
|
40
|
+
CatalogIndex,
|
|
41
|
+
OverlayTui,
|
|
42
|
+
PanelId,
|
|
43
|
+
ProviderEntry,
|
|
44
|
+
Theme,
|
|
45
|
+
} from "../ui-picker/types.ts";
|
|
46
|
+
import { clampScroll, takeSlice, visibleWidth } from "./shell.ts";
|
|
47
|
+
import type { HubView } from "./types.ts";
|
|
48
|
+
|
|
49
|
+
export interface ModelsViewDeps {
|
|
50
|
+
tui: OverlayTui;
|
|
51
|
+
theme: Theme;
|
|
52
|
+
ctx: ExtensionCommandContext;
|
|
53
|
+
cfg: ProxyConfig;
|
|
54
|
+
getDiscovery: () => Discovery;
|
|
55
|
+
/** Called whenever the user mutates the config (attach/detach/new/delete). */
|
|
56
|
+
onChange?: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ModelsView extends HubView {
|
|
60
|
+
/** Rebuild catalog + provider list after a discovery refresh. */
|
|
61
|
+
rebuild(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildModelsView(deps: ModelsViewDeps): ModelsView {
|
|
65
|
+
const { tui, theme, ctx, cfg } = deps;
|
|
66
|
+
const onChange = deps.onChange ?? ((): void => {});
|
|
67
|
+
|
|
68
|
+
let catalog: CatalogIndex = buildCatalog(deps.getDiscovery());
|
|
69
|
+
let providers: ProviderEntry[] = collectProviders(cfg, catalog);
|
|
70
|
+
|
|
71
|
+
let focus: PanelId = "providers";
|
|
72
|
+
let providerCursor = 0;
|
|
73
|
+
let assignedCursor = 0;
|
|
74
|
+
let poolCursor = 0;
|
|
75
|
+
let providerScroll = 0;
|
|
76
|
+
let assignedScroll = 0;
|
|
77
|
+
let poolScroll = 0;
|
|
78
|
+
|
|
79
|
+
let filter = "";
|
|
80
|
+
let filterEditing = false;
|
|
81
|
+
|
|
82
|
+
const selectedProvider = (): ProviderEntry | null => {
|
|
83
|
+
if (providers.length === 0) return null;
|
|
84
|
+
const idx = Math.max(0, Math.min(providerCursor, providers.length - 1));
|
|
85
|
+
return providers[idx] ?? null;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ----- pool ordering (single source of truth) ---------------------------
|
|
89
|
+
const poolGroups = (
|
|
90
|
+
prov: ProviderEntry,
|
|
91
|
+
): Array<{ label: string; ids: string[] }> => {
|
|
92
|
+
const ids = filterModelIds(poolFor(cfg, prov, catalog), catalog, filter);
|
|
93
|
+
return groupPoolByOwnedBy(ids, catalog);
|
|
94
|
+
};
|
|
95
|
+
const poolOrder = (prov: ProviderEntry): string[] =>
|
|
96
|
+
poolGroups(prov).flatMap((g) => g.ids);
|
|
97
|
+
|
|
98
|
+
const refresh = (): void => {
|
|
99
|
+
providers = collectProviders(cfg, catalog);
|
|
100
|
+
const maxProv = Math.max(0, providers.length); // +1 "new" row
|
|
101
|
+
if (providerCursor > maxProv) providerCursor = maxProv;
|
|
102
|
+
const prov = selectedProvider();
|
|
103
|
+
if (prov) {
|
|
104
|
+
const aLen = assignedIdsFor(cfg, prov).length;
|
|
105
|
+
const pLen = poolOrder(prov).length;
|
|
106
|
+
if (assignedCursor >= aLen) assignedCursor = Math.max(0, aLen - 1);
|
|
107
|
+
if (poolCursor >= pLen) poolCursor = Math.max(0, pLen - 1);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const rebuild = (): void => {
|
|
112
|
+
catalog = buildCatalog(deps.getDiscovery());
|
|
113
|
+
refresh();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ----- navigation -------------------------------------------------------
|
|
117
|
+
const onTab = (back: boolean): void => {
|
|
118
|
+
const order: PanelId[] = ["providers", "assigned", "pool"];
|
|
119
|
+
const i = order.indexOf(focus);
|
|
120
|
+
focus = back
|
|
121
|
+
? order[(i - 1 + order.length) % order.length]!
|
|
122
|
+
: order[(i + 1) % order.length]!;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const moveCursor = (delta: number): void => {
|
|
126
|
+
if (focus === "providers") {
|
|
127
|
+
const total = providers.length + 1; // +1 "new" row
|
|
128
|
+
providerCursor = Math.max(0, Math.min(providerCursor + delta, total - 1));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const prov = selectedProvider();
|
|
132
|
+
if (!prov) return;
|
|
133
|
+
if (focus === "assigned") {
|
|
134
|
+
const total = assignedIdsFor(cfg, prov).length;
|
|
135
|
+
if (total === 0) return;
|
|
136
|
+
assignedCursor = Math.max(0, Math.min(assignedCursor + delta, total - 1));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const total = poolOrder(prov).length;
|
|
140
|
+
if (total === 0) return;
|
|
141
|
+
poolCursor = Math.max(0, Math.min(poolCursor + delta, total - 1));
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const onActivate = async (): Promise<void> => {
|
|
145
|
+
if (focus === "providers") {
|
|
146
|
+
if (providerCursor === providers.length) {
|
|
147
|
+
const name = await promptNewProviderName(ctx, cfg.proxy.providerPrefix);
|
|
148
|
+
if (name && !cfg.customProviders[name]) {
|
|
149
|
+
cfg.customProviders[name] = { api: "openai-completions", models: [] };
|
|
150
|
+
onChange();
|
|
151
|
+
refresh();
|
|
152
|
+
providerCursor = providers.findIndex(
|
|
153
|
+
(p) => p.kind === "custom" && p.name === name,
|
|
154
|
+
);
|
|
155
|
+
if (providerCursor < 0) providerCursor = providers.length - 1;
|
|
156
|
+
focus = "pool";
|
|
157
|
+
}
|
|
158
|
+
tui.requestRender();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
focus = "pool";
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const prov = selectedProvider();
|
|
165
|
+
if (!prov) return;
|
|
166
|
+
if (focus === "assigned") {
|
|
167
|
+
const id = assignedIdsFor(cfg, prov)[assignedCursor];
|
|
168
|
+
if (!id) return;
|
|
169
|
+
detachModel(cfg, prov, id);
|
|
170
|
+
onChange();
|
|
171
|
+
refresh();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const id = poolOrder(prov)[poolCursor];
|
|
175
|
+
if (!id) return;
|
|
176
|
+
const m = catalog.byId.get(id);
|
|
177
|
+
if (!m) return;
|
|
178
|
+
attachModel(cfg, prov, m);
|
|
179
|
+
onChange();
|
|
180
|
+
refresh();
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const onDelete = async (): Promise<void> => {
|
|
184
|
+
if (focus !== "providers") return;
|
|
185
|
+
const prov = selectedProvider();
|
|
186
|
+
if (!prov || prov.kind !== "custom") return;
|
|
187
|
+
const ok = await confirmRemoveProvider(ctx, prov.name);
|
|
188
|
+
if (!ok) {
|
|
189
|
+
tui.requestRender();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
delete cfg.customProviders[prov.name];
|
|
193
|
+
onChange();
|
|
194
|
+
refresh();
|
|
195
|
+
if (providerCursor >= providers.length)
|
|
196
|
+
providerCursor = Math.max(0, providers.length - 1);
|
|
197
|
+
tui.requestRender();
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// ----- render -----------------------------------------------------------
|
|
201
|
+
const render = (width: number, height: number): string[] =>
|
|
202
|
+
renderBody(width, height);
|
|
203
|
+
|
|
204
|
+
function renderBody(width: number, height: number): string[] {
|
|
205
|
+
const inner = Math.max(70, width);
|
|
206
|
+
const leftW = Math.min(56, Math.max(34, Math.floor(inner * 0.4)));
|
|
207
|
+
const rightW = inner - leftW - 1; // -1 for the vertical splitter
|
|
208
|
+
const bodyH = Math.max(8, height);
|
|
209
|
+
const upperH = Math.max(5, Math.floor((bodyH - 1) / 2));
|
|
210
|
+
const lowerH = bodyH - upperH;
|
|
211
|
+
|
|
212
|
+
const prov = selectedProvider();
|
|
213
|
+
|
|
214
|
+
let leftCursorLine = 0;
|
|
215
|
+
let leftCursorTop = 0;
|
|
216
|
+
let assignedCursorLine = 0;
|
|
217
|
+
let assignedCursorTop = 0;
|
|
218
|
+
let poolCursorLine = 0;
|
|
219
|
+
let poolCursorTop = 0;
|
|
220
|
+
|
|
221
|
+
// LEFT — providers
|
|
222
|
+
const leftLines: string[] = [];
|
|
223
|
+
leftLines.push(panelHeaderBar(" providers ", leftW, focus === "providers"));
|
|
224
|
+
for (let i = 0; i < providers.length; i++) {
|
|
225
|
+
const p = providers[i]!;
|
|
226
|
+
const isCursor = focus === "providers" && i === providerCursor;
|
|
227
|
+
if (isCursor) {
|
|
228
|
+
leftCursorLine = leftLines.length;
|
|
229
|
+
leftCursorTop = leftCursorLine;
|
|
230
|
+
}
|
|
231
|
+
leftLines.push(
|
|
232
|
+
renderProviderRow(p, cfg, {
|
|
233
|
+
theme,
|
|
234
|
+
width: leftW,
|
|
235
|
+
isCursor,
|
|
236
|
+
isFocused: focus === "providers",
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
{
|
|
241
|
+
const isCursor =
|
|
242
|
+
focus === "providers" && providerCursor === providers.length;
|
|
243
|
+
if (isCursor) {
|
|
244
|
+
leftCursorLine = leftLines.length;
|
|
245
|
+
leftCursorTop = leftCursorLine;
|
|
246
|
+
}
|
|
247
|
+
leftLines.push(
|
|
248
|
+
renderNewProviderRow(theme, isCursor, focus === "providers", leftW),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// RIGHT TOP — assigned
|
|
253
|
+
const assignedLines: string[] = [];
|
|
254
|
+
const assignedHeader = prov
|
|
255
|
+
? `assigned to ${theme.bold(prov.name)} ${theme.fg("dim", `\u00b7 ${prov.api}`)}`
|
|
256
|
+
: theme.fg("dim", "no provider selected");
|
|
257
|
+
assignedLines.push(
|
|
258
|
+
panelHeaderBar(" assigned ", rightW, focus === "assigned"),
|
|
259
|
+
);
|
|
260
|
+
assignedLines.push(
|
|
261
|
+
renderPanelHeader(theme, assignedHeader, rightW, focus === "assigned"),
|
|
262
|
+
);
|
|
263
|
+
if (prov) {
|
|
264
|
+
const ids = assignedIdsFor(cfg, prov);
|
|
265
|
+
if (ids.length === 0)
|
|
266
|
+
assignedLines.push(
|
|
267
|
+
renderEmpty(theme, "(nothing assigned yet)", rightW),
|
|
268
|
+
);
|
|
269
|
+
for (let i = 0; i < ids.length; i++) {
|
|
270
|
+
const id = ids[i]!;
|
|
271
|
+
const m = catalog.byId.get(id);
|
|
272
|
+
const isCursor = focus === "assigned" && i === assignedCursor;
|
|
273
|
+
if (isCursor) {
|
|
274
|
+
assignedCursorLine = assignedLines.length;
|
|
275
|
+
assignedCursorTop = assignedCursorLine;
|
|
276
|
+
}
|
|
277
|
+
const compatWarn = m ? !apiCompatible(prov.api, m.suggestedApi) : false;
|
|
278
|
+
assignedLines.push(
|
|
279
|
+
renderModelRow(id, m, "assigned", compatWarn, {
|
|
280
|
+
theme,
|
|
281
|
+
width: rightW,
|
|
282
|
+
isCursor,
|
|
283
|
+
isFocused: focus === "assigned",
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// RIGHT BOTTOM — pool, grouped by ownedBy
|
|
290
|
+
const poolLines: string[] = [];
|
|
291
|
+
poolLines.push(poolHeaderBar(rightW, focus === "pool"));
|
|
292
|
+
if (prov) {
|
|
293
|
+
const groups = poolGroups(prov);
|
|
294
|
+
const flatCount = groups.reduce((n, g) => n + g.ids.length, 0);
|
|
295
|
+
if (flatCount === 0) {
|
|
296
|
+
poolLines.push(
|
|
297
|
+
renderEmpty(
|
|
298
|
+
theme,
|
|
299
|
+
filter ? `(no matches for "${filter}")` : "(no models available)",
|
|
300
|
+
rightW,
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
} else {
|
|
304
|
+
let cursorIdx = 0;
|
|
305
|
+
for (const grp of groups) {
|
|
306
|
+
const groupHeaderLine = poolLines.length;
|
|
307
|
+
poolLines.push(renderSubheader(theme, grp.label, rightW));
|
|
308
|
+
for (let gi = 0; gi < grp.ids.length; gi++) {
|
|
309
|
+
const id = grp.ids[gi]!;
|
|
310
|
+
const m = catalog.byId.get(id);
|
|
311
|
+
const isCursor = focus === "pool" && cursorIdx === poolCursor;
|
|
312
|
+
if (isCursor) {
|
|
313
|
+
poolCursorLine = poolLines.length;
|
|
314
|
+
poolCursorTop = gi === 0 ? groupHeaderLine : poolCursorLine;
|
|
315
|
+
}
|
|
316
|
+
const compatWarn = m
|
|
317
|
+
? !apiCompatible(prov.api, m.suggestedApi)
|
|
318
|
+
: false;
|
|
319
|
+
poolLines.push(
|
|
320
|
+
renderModelRow(id, m, "pool", compatWarn, {
|
|
321
|
+
theme,
|
|
322
|
+
width: rightW,
|
|
323
|
+
isCursor,
|
|
324
|
+
isFocused: focus === "pool",
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
cursorIdx++;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// scroll
|
|
334
|
+
const poolUsableH = Math.max(1, lowerH - 1); // -1 horizontal divider
|
|
335
|
+
const leftScroll = clampScroll(
|
|
336
|
+
leftCursorTop,
|
|
337
|
+
leftCursorLine,
|
|
338
|
+
providerScroll,
|
|
339
|
+
bodyH,
|
|
340
|
+
leftLines.length,
|
|
341
|
+
1,
|
|
342
|
+
);
|
|
343
|
+
const aScroll = clampScroll(
|
|
344
|
+
focus === "assigned" ? assignedCursorTop : 0,
|
|
345
|
+
focus === "assigned" ? assignedCursorLine : 0,
|
|
346
|
+
assignedScroll,
|
|
347
|
+
upperH,
|
|
348
|
+
assignedLines.length,
|
|
349
|
+
2,
|
|
350
|
+
);
|
|
351
|
+
const pScroll = clampScroll(
|
|
352
|
+
focus === "pool" ? poolCursorTop : 0,
|
|
353
|
+
focus === "pool" ? poolCursorLine : 0,
|
|
354
|
+
poolScroll,
|
|
355
|
+
poolUsableH,
|
|
356
|
+
poolLines.length,
|
|
357
|
+
1,
|
|
358
|
+
);
|
|
359
|
+
providerScroll = leftScroll;
|
|
360
|
+
assignedScroll = aScroll;
|
|
361
|
+
poolScroll = pScroll;
|
|
362
|
+
|
|
363
|
+
const leftSlice = takeSlice(leftLines, leftScroll, bodyH);
|
|
364
|
+
const aSlice = takeSlice(assignedLines, aScroll, upperH);
|
|
365
|
+
const pSlice = takeSlice(poolLines, pScroll, lowerH);
|
|
366
|
+
|
|
367
|
+
const vsplit = theme.fg("borderAccent", "\u2502");
|
|
368
|
+
const out: string[] = [];
|
|
369
|
+
for (let i = 0; i < bodyH; i++) {
|
|
370
|
+
const l = pad(leftSlice[i] ?? "", leftW);
|
|
371
|
+
const rRaw = i < upperH ? aSlice[i]! : pSlice[i - upperH]!;
|
|
372
|
+
const r = pad(rRaw, rightW);
|
|
373
|
+
out.push(`${l}${vsplit}${r}`);
|
|
374
|
+
}
|
|
375
|
+
const divIdx = upperH;
|
|
376
|
+
if (divIdx > 0 && divIdx < bodyH) {
|
|
377
|
+
const left = pad(leftSlice[divIdx] ?? "", leftW);
|
|
378
|
+
const horiz = theme.fg("borderAccent", "\u2500".repeat(rightW));
|
|
379
|
+
out[divIdx] = `${left}${vsplit}${horiz}`;
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function panelHeaderBar(
|
|
385
|
+
label: string,
|
|
386
|
+
width: number,
|
|
387
|
+
isFocused: boolean,
|
|
388
|
+
): string {
|
|
389
|
+
const bar = isFocused
|
|
390
|
+
? theme.bold(theme.fg("accent", label))
|
|
391
|
+
: theme.fg("muted", label);
|
|
392
|
+
const fill = theme.fg(
|
|
393
|
+
"borderAccent",
|
|
394
|
+
"\u2500".repeat(Math.max(0, width - visibleWidth(label) - 1)),
|
|
395
|
+
);
|
|
396
|
+
return pad(`${bar}${fill}`, width);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function poolHeaderBar(width: number, isFocused: boolean): string {
|
|
400
|
+
const label = " available pool ";
|
|
401
|
+
const bar = isFocused
|
|
402
|
+
? theme.bold(theme.fg("accent", label))
|
|
403
|
+
: theme.fg("muted", label);
|
|
404
|
+
let filterChip = "";
|
|
405
|
+
if (filterEditing) {
|
|
406
|
+
filterChip = `${theme.fg("dim", " /")}${theme.fg("accent", filter)}${theme.fg("accent", "\u2588")} `;
|
|
407
|
+
} else if (filter) {
|
|
408
|
+
filterChip = `${theme.fg("dim", " /")}${theme.fg("warning", filter)} `;
|
|
409
|
+
}
|
|
410
|
+
const used = visibleWidth(label) + visibleWidth(filterChip);
|
|
411
|
+
const fill = theme.fg(
|
|
412
|
+
"borderAccent",
|
|
413
|
+
"\u2500".repeat(Math.max(0, width - used - 1)),
|
|
414
|
+
);
|
|
415
|
+
return pad(`${bar}${filterChip}${fill}`, width);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ----- input ------------------------------------------------------------
|
|
419
|
+
function handleInput(data: string): boolean | Promise<boolean> {
|
|
420
|
+
// Filter editing captures text first.
|
|
421
|
+
if (filterEditing) {
|
|
422
|
+
if (matchesKey(data, "escape")) {
|
|
423
|
+
filter = "";
|
|
424
|
+
filterEditing = false;
|
|
425
|
+
poolCursor = 0;
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return")) {
|
|
429
|
+
filterEditing = false;
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
if (matchesKey(data, "backspace") || matchesKey(data, "delete")) {
|
|
433
|
+
filter = filter.slice(0, -1);
|
|
434
|
+
poolCursor = 0;
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
if (data.length === 1 && data >= " " && data !== "\x7f") {
|
|
438
|
+
filter += data;
|
|
439
|
+
poolCursor = 0;
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
return true; // swallow everything while editing
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (matchesKey(data, "tab")) {
|
|
446
|
+
onTab(false);
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
if (matchesKey(data, "shift+tab")) {
|
|
450
|
+
onTab(true);
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
if (matchesKey(data, "/")) {
|
|
454
|
+
focus = "pool";
|
|
455
|
+
filterEditing = true;
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
if (
|
|
459
|
+
matchesKey(data, "enter") ||
|
|
460
|
+
matchesKey(data, "return") ||
|
|
461
|
+
matchesKey(data, "space")
|
|
462
|
+
) {
|
|
463
|
+
return onActivate().then(() => true);
|
|
464
|
+
}
|
|
465
|
+
if (
|
|
466
|
+
focus === "providers" &&
|
|
467
|
+
(matchesKey(data, "d") ||
|
|
468
|
+
matchesKey(data, "delete") ||
|
|
469
|
+
matchesKey(data, "backspace"))
|
|
470
|
+
) {
|
|
471
|
+
return onDelete().then(() => true);
|
|
472
|
+
}
|
|
473
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
474
|
+
moveCursor(-1);
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
478
|
+
moveCursor(1);
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
if (matchesKey(data, "pageUp") || matchesKey(data, "b")) {
|
|
482
|
+
moveCursor(-8);
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
if (matchesKey(data, "pageDown") || matchesKey(data, "f")) {
|
|
486
|
+
moveCursor(8);
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
if (matchesKey(data, "left") || matchesKey(data, "h")) {
|
|
490
|
+
focus = "providers";
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
if (matchesKey(data, "right") || matchesKey(data, "l")) {
|
|
494
|
+
if (focus === "providers") focus = "assigned";
|
|
495
|
+
else if (focus === "assigned") focus = "pool";
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function footerHint(): string {
|
|
502
|
+
if (filterEditing)
|
|
503
|
+
return " type to filter \u00b7 \u21b5 apply \u00b7 esc clear ";
|
|
504
|
+
return " tab \u2194 panel \u00b7 \u2191\u2193 nav \u00b7 \u21b5 move \u00b7 / filter \u00b7 d remove group ";
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
id: "models",
|
|
509
|
+
label: "Models",
|
|
510
|
+
render,
|
|
511
|
+
handleInput,
|
|
512
|
+
footerHint,
|
|
513
|
+
rebuild,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Usage view — wraps renderUsage() in a scrollable hub tab with d/v toggles.
|
|
2
|
+
// The /api/usage document is fetched lazily the first time the tab is opened.
|
|
3
|
+
|
|
4
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
5
|
+
|
|
6
|
+
import type { ProxyConfig } from "../config.ts";
|
|
7
|
+
import { resolveConfigValue } from "../config.ts";
|
|
8
|
+
import {
|
|
9
|
+
clearUsageCache,
|
|
10
|
+
fetchUsage,
|
|
11
|
+
type UsageDocument,
|
|
12
|
+
} from "../fetch-usage.ts";
|
|
13
|
+
import { pad } from "../ui-picker/render-text.ts";
|
|
14
|
+
import type { OverlayTui, Theme } from "../ui-picker/types.ts";
|
|
15
|
+
import { renderUsage } from "../ui-usage.ts";
|
|
16
|
+
import { clampOffset, takeSlice } from "./shell.ts";
|
|
17
|
+
import type { HubView } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
export interface UsageViewDeps {
|
|
20
|
+
tui: OverlayTui;
|
|
21
|
+
theme: Theme;
|
|
22
|
+
cfg: ProxyConfig;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UsageView extends HubView {
|
|
26
|
+
/** Drop the cache and refetch (called by the hub's global refresh). */
|
|
27
|
+
reload(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildUsageView(deps: UsageViewDeps): UsageView {
|
|
31
|
+
const { tui, theme, cfg } = deps;
|
|
32
|
+
let status: "idle" | "loading" | "ready" | "error" = "idle";
|
|
33
|
+
let doc: UsageDocument | null = null;
|
|
34
|
+
let errMsg = "";
|
|
35
|
+
let offset = 0;
|
|
36
|
+
let showDisabled = false;
|
|
37
|
+
let verbose = false;
|
|
38
|
+
|
|
39
|
+
const load = (force: boolean): void => {
|
|
40
|
+
status = "loading";
|
|
41
|
+
tui.requestRender();
|
|
42
|
+
const usageKey = resolveConfigValue(cfg.proxy.usageKey);
|
|
43
|
+
void fetchUsage(cfg, usageKey, { force })
|
|
44
|
+
.then((d) => {
|
|
45
|
+
doc = d;
|
|
46
|
+
status = "ready";
|
|
47
|
+
offset = 0;
|
|
48
|
+
tui.requestRender();
|
|
49
|
+
})
|
|
50
|
+
.catch((e: unknown) => {
|
|
51
|
+
errMsg = (e as Error).message;
|
|
52
|
+
status = "error";
|
|
53
|
+
tui.requestRender();
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const lines = (): string[] => {
|
|
58
|
+
if (status === "idle" || status === "loading")
|
|
59
|
+
return [theme.fg("dim", " loading usage\u2026")];
|
|
60
|
+
if (status === "error")
|
|
61
|
+
return [theme.fg("error", ` usage failed: ${errMsg}`)];
|
|
62
|
+
if (!doc) return [theme.fg("dim", " (no data)")];
|
|
63
|
+
return renderUsage(doc, { showDisabled, verbose });
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const render = (width: number, height: number): string[] => {
|
|
67
|
+
const body = lines();
|
|
68
|
+
offset = clampOffset(offset, height, body.length);
|
|
69
|
+
return takeSlice(body, offset, height).map((ln) => pad(` ${ln}`, width));
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleInput = (data: string): boolean => {
|
|
73
|
+
if (matchesKey(data, "d")) {
|
|
74
|
+
showDisabled = !showDisabled;
|
|
75
|
+
offset = 0;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
if (matchesKey(data, "v")) {
|
|
79
|
+
verbose = !verbose;
|
|
80
|
+
offset = 0;
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
const total = lines().length;
|
|
84
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
85
|
+
offset = Math.max(0, offset - 1);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
89
|
+
offset = Math.min(Math.max(0, total - 1), offset + 1);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (matchesKey(data, "pageUp") || matchesKey(data, "b")) {
|
|
93
|
+
offset = Math.max(0, offset - 10);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (matchesKey(data, "pageDown") || matchesKey(data, "f")) {
|
|
97
|
+
offset = Math.min(Math.max(0, total - 1), offset + 10);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
if (matchesKey(data, "home") || matchesKey(data, "g")) {
|
|
101
|
+
offset = 0;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
if (matchesKey(data, "end") || matchesKey(data, "shift+g")) {
|
|
105
|
+
offset = Math.max(0, total - 1);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const footerHint = (): string => {
|
|
112
|
+
const d = showDisabled ? theme.fg("success", "[d disabled]") : "d disabled";
|
|
113
|
+
const v = verbose ? theme.fg("success", "[v verbose]") : "v verbose";
|
|
114
|
+
return ` \u2191\u2193 scroll \u00b7 ${d} \u00b7 ${v} `;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
id: "usage",
|
|
119
|
+
label: "Usage",
|
|
120
|
+
render,
|
|
121
|
+
handleInput,
|
|
122
|
+
footerHint,
|
|
123
|
+
onActivate: () => {
|
|
124
|
+
if (status === "idle") load(false);
|
|
125
|
+
},
|
|
126
|
+
reload: () => {
|
|
127
|
+
clearUsageCache();
|
|
128
|
+
if (status !== "idle") load(true);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Build a fast lookup of every model the proxy currently offers, split into
|
|
2
|
+
// built-in (by provider name) and the custom pool. The result is consumed by
|
|
3
|
+
// providers.ts and mutate.ts.
|
|
4
|
+
|
|
5
|
+
import type { Discovery } from "../fetch-models.ts";
|
|
6
|
+
import type { CatalogIndex, ModelEntry } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
export function buildCatalog(discovery: Discovery): CatalogIndex {
|
|
9
|
+
const byId = new Map<string, ModelEntry>();
|
|
10
|
+
const builtinModelIds = new Map<string, string[]>();
|
|
11
|
+
|
|
12
|
+
for (const p of discovery.builtinProviders) {
|
|
13
|
+
const ids: string[] = [];
|
|
14
|
+
for (const m of p.models) {
|
|
15
|
+
byId.set(m.id, {
|
|
16
|
+
id: m.id,
|
|
17
|
+
name: m.name,
|
|
18
|
+
suggestedApi: p.api,
|
|
19
|
+
subtitle: m.name && m.name !== m.id ? m.name : undefined,
|
|
20
|
+
ownedBy: p.name,
|
|
21
|
+
reasoning: m.reasoning,
|
|
22
|
+
contextWindow: m.contextWindow,
|
|
23
|
+
maxTokens: m.maxTokens,
|
|
24
|
+
cost: m.cost,
|
|
25
|
+
});
|
|
26
|
+
ids.push(m.id);
|
|
27
|
+
}
|
|
28
|
+
builtinModelIds.set(p.name, ids);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const customPoolIds: string[] = [];
|
|
32
|
+
for (const m of discovery.customPool) {
|
|
33
|
+
// Don't overwrite a built-in entry if the same id appears in both.
|
|
34
|
+
if (!byId.has(m.id)) {
|
|
35
|
+
byId.set(m.id, {
|
|
36
|
+
id: m.id,
|
|
37
|
+
name: m.name,
|
|
38
|
+
suggestedApi: m.api,
|
|
39
|
+
subtitle: m.name && m.name !== m.id ? m.name : undefined,
|
|
40
|
+
origin: m.suggestedProvider,
|
|
41
|
+
ownedBy: m.ownedBy,
|
|
42
|
+
reasoning: m.reasoning,
|
|
43
|
+
contextWindow: m.contextWindow,
|
|
44
|
+
maxTokens: m.maxTokens,
|
|
45
|
+
cost: m.cost,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
customPoolIds.push(m.id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { byId, builtinModelIds, customPoolIds };
|
|
52
|
+
}
|