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,167 @@
|
|
|
1
|
+
// State mutations + read helpers shared by the picker UI.
|
|
2
|
+
// Nothing in this module touches the rendering layer.
|
|
3
|
+
|
|
4
|
+
import type { Api } from "@earendil-works/pi-ai";
|
|
5
|
+
|
|
6
|
+
import type { CustomProviderModelConfig, ProxyConfig } from "../config.ts";
|
|
7
|
+
import type { CatalogIndex, ModelEntry, ProviderEntry } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** model id \u2192 custom-provider slug that has claimed it (single owner). */
|
|
10
|
+
export function claimedBy(cfg: ProxyConfig): Map<string, string> {
|
|
11
|
+
const m = new Map<string, string>();
|
|
12
|
+
for (const [slug, p] of Object.entries(cfg.customProviders)) {
|
|
13
|
+
for (const mm of p.models) m.set(mm.id, slug);
|
|
14
|
+
}
|
|
15
|
+
return m;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function assignedIdsFor(
|
|
19
|
+
cfg: ProxyConfig,
|
|
20
|
+
prov: ProviderEntry,
|
|
21
|
+
): string[] {
|
|
22
|
+
if (prov.kind === "builtin") {
|
|
23
|
+
return [...(cfg.builtinProviders[prov.name]?.models ?? [])];
|
|
24
|
+
}
|
|
25
|
+
return cfg.customProviders[prov.name]?.models.map((m) => m.id) ?? [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function poolFor(
|
|
29
|
+
cfg: ProxyConfig,
|
|
30
|
+
prov: ProviderEntry,
|
|
31
|
+
catalog: CatalogIndex,
|
|
32
|
+
): string[] {
|
|
33
|
+
if (prov.kind === "builtin") {
|
|
34
|
+
const ids = catalog.builtinModelIds.get(prov.name) ?? [];
|
|
35
|
+
const assigned = new Set(assignedIdsFor(cfg, prov));
|
|
36
|
+
return ids.filter((id) => !assigned.has(id));
|
|
37
|
+
}
|
|
38
|
+
const claim = claimedBy(cfg);
|
|
39
|
+
const assigned = new Set(assignedIdsFor(cfg, prov));
|
|
40
|
+
return catalog.customPoolIds.filter((id) => {
|
|
41
|
+
if (assigned.has(id)) return false;
|
|
42
|
+
const owner = claim.get(id);
|
|
43
|
+
return owner === undefined || owner === prov.name;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toEntry(m: ModelEntry): CustomProviderModelConfig {
|
|
48
|
+
return {
|
|
49
|
+
id: m.id,
|
|
50
|
+
name: m.name,
|
|
51
|
+
contextWindow: m.contextWindow,
|
|
52
|
+
maxTokens: m.maxTokens,
|
|
53
|
+
reasoning: m.reasoning,
|
|
54
|
+
cost: m.cost,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function attachModel(
|
|
59
|
+
cfg: ProxyConfig,
|
|
60
|
+
prov: ProviderEntry,
|
|
61
|
+
model: ModelEntry,
|
|
62
|
+
): void {
|
|
63
|
+
if (prov.kind === "builtin") {
|
|
64
|
+
const cur = cfg.builtinProviders[prov.name] ?? {
|
|
65
|
+
enabled: true,
|
|
66
|
+
models: [],
|
|
67
|
+
};
|
|
68
|
+
const set = new Set(cur.models);
|
|
69
|
+
set.add(model.id);
|
|
70
|
+
cfg.builtinProviders[prov.name] = {
|
|
71
|
+
enabled: true,
|
|
72
|
+
apiOverride: cur.apiOverride ?? null,
|
|
73
|
+
models: Array.from(set).sort(),
|
|
74
|
+
};
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// custom \u2014 exclusive: remove from any other custom group first.
|
|
78
|
+
for (const [slug, p] of Object.entries(cfg.customProviders)) {
|
|
79
|
+
if (slug === prov.name) continue;
|
|
80
|
+
const i = p.models.findIndex((mm) => mm.id === model.id);
|
|
81
|
+
if (i >= 0) p.models.splice(i, 1);
|
|
82
|
+
}
|
|
83
|
+
const cur = cfg.customProviders[prov.name] ?? { api: prov.api, models: [] };
|
|
84
|
+
if (!cur.models.some((mm) => mm.id === model.id)) {
|
|
85
|
+
cur.models.push(toEntry(model));
|
|
86
|
+
}
|
|
87
|
+
cfg.customProviders[prov.name] = cur;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function detachModel(
|
|
91
|
+
cfg: ProxyConfig,
|
|
92
|
+
prov: ProviderEntry,
|
|
93
|
+
modelId: string,
|
|
94
|
+
): void {
|
|
95
|
+
if (prov.kind === "builtin") {
|
|
96
|
+
const cur = cfg.builtinProviders[prov.name];
|
|
97
|
+
if (!cur) return;
|
|
98
|
+
cur.models = cur.models.filter((id) => id !== modelId);
|
|
99
|
+
cur.enabled = cur.models.length > 0;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const cur = cfg.customProviders[prov.name];
|
|
103
|
+
if (!cur) return;
|
|
104
|
+
cur.models = cur.models.filter((mm) => mm.id !== modelId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function apiCompatible(provApi: Api, modelApi: Api): boolean {
|
|
108
|
+
if (provApi === modelApi) return true;
|
|
109
|
+
// openai-completions and openai-responses are siblings \u2014 most models work
|
|
110
|
+
// with both, so don't warn when they differ.
|
|
111
|
+
const openaiFamily: Api[] = ["openai-completions", "openai-responses"];
|
|
112
|
+
if (openaiFamily.includes(provApi) && openaiFamily.includes(modelApi))
|
|
113
|
+
return true;
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Group pool model ids by `ownedBy` (fallback: `origin`, then "misc"). */
|
|
118
|
+
export function groupPoolByOwnedBy(
|
|
119
|
+
ids: string[],
|
|
120
|
+
catalog: CatalogIndex,
|
|
121
|
+
): Array<{ label: string; ids: string[] }> {
|
|
122
|
+
const groups = new Map<string, string[]>();
|
|
123
|
+
for (const id of ids) {
|
|
124
|
+
const m = catalog.byId.get(id);
|
|
125
|
+
const key = m?.ownedBy || m?.origin || "misc";
|
|
126
|
+
const arr = groups.get(key) ?? [];
|
|
127
|
+
arr.push(id);
|
|
128
|
+
groups.set(key, arr);
|
|
129
|
+
}
|
|
130
|
+
return Array.from(groups.entries())
|
|
131
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
132
|
+
.map(([label, arr]) => ({ label, ids: arr }));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Substring filter over model id + display name (case-insensitive). Empty
|
|
137
|
+
* query returns the input untouched. Used by the pool filter box.
|
|
138
|
+
*/
|
|
139
|
+
export function filterModelIds(
|
|
140
|
+
ids: string[],
|
|
141
|
+
catalog: CatalogIndex,
|
|
142
|
+
query: string,
|
|
143
|
+
): string[] {
|
|
144
|
+
const q = query.trim().toLowerCase();
|
|
145
|
+
if (!q) return ids;
|
|
146
|
+
return ids.filter((id) => {
|
|
147
|
+
if (id.toLowerCase().includes(q)) return true;
|
|
148
|
+
const name = catalog.byId.get(id)?.name;
|
|
149
|
+
return name ? name.toLowerCase().includes(q) : false;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Single source of truth for pool ordering: the grouped order flattened. The
|
|
155
|
+
* picker MUST index navigation + activation through this so the visually
|
|
156
|
+
* highlighted row always maps to the model that gets toggled. (Rendering
|
|
157
|
+
* iterates the same `groupPoolByOwnedBy` groups, so indices line up exactly.)
|
|
158
|
+
*/
|
|
159
|
+
export function poolDisplayOrder(
|
|
160
|
+
cfg: ProxyConfig,
|
|
161
|
+
prov: ProviderEntry,
|
|
162
|
+
catalog: CatalogIndex,
|
|
163
|
+
filter = "",
|
|
164
|
+
): string[] {
|
|
165
|
+
const ids = filterModelIds(poolFor(cfg, prov, catalog), catalog, filter);
|
|
166
|
+
return groupPoolByOwnedBy(ids, catalog).flatMap((g) => g.ids);
|
|
167
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Confirm-remove prompt overlay used when the user deletes a custom group.
|
|
2
|
+
|
|
3
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
type Component,
|
|
6
|
+
getKeybindings,
|
|
7
|
+
matchesKey,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
import { frame } from "../ui-frame.ts";
|
|
11
|
+
import type { Theme } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
export async function confirmRemoveProvider(
|
|
14
|
+
ctx: ExtensionCommandContext,
|
|
15
|
+
slug: string,
|
|
16
|
+
): Promise<boolean> {
|
|
17
|
+
return ctx.ui.custom<boolean>(
|
|
18
|
+
(_tui, theme, _kb, done) =>
|
|
19
|
+
buildConfirmPrompt(
|
|
20
|
+
theme as unknown as Theme,
|
|
21
|
+
`Remove custom group \u201c${slug}\u201d?`,
|
|
22
|
+
"All model assignments in this group will be discarded.",
|
|
23
|
+
done,
|
|
24
|
+
),
|
|
25
|
+
{ overlay: true, overlayOptions: { width: 80, maxHeight: "40%" } },
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildConfirmPrompt(
|
|
30
|
+
theme: Theme,
|
|
31
|
+
title: string,
|
|
32
|
+
body: string,
|
|
33
|
+
done: (v: boolean) => void,
|
|
34
|
+
): Component & { handleInput(data: string): void } {
|
|
35
|
+
return {
|
|
36
|
+
render(width: number): string[] {
|
|
37
|
+
return frame(theme, {
|
|
38
|
+
width,
|
|
39
|
+
title: " confirm ",
|
|
40
|
+
titleColor: "error",
|
|
41
|
+
lines: ["", ` ${theme.bold(title)}`, ` ${theme.fg("dim", body)}`, ""],
|
|
42
|
+
footer: {
|
|
43
|
+
hint: " y / enter = remove \u00b7 n / esc = cancel ",
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
invalidate(): void {
|
|
48
|
+
/* stateless */
|
|
49
|
+
},
|
|
50
|
+
handleInput(data: string): void {
|
|
51
|
+
const kb = getKeybindings();
|
|
52
|
+
if (
|
|
53
|
+
kb.matches(data, "tui.select.cancel") ||
|
|
54
|
+
matchesKey(data, "escape") ||
|
|
55
|
+
matchesKey(data, "n") ||
|
|
56
|
+
matchesKey(data, "q")
|
|
57
|
+
) {
|
|
58
|
+
done(false);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
matchesKey(data, "y") ||
|
|
63
|
+
matchesKey(data, "enter") ||
|
|
64
|
+
matchesKey(data, "return")
|
|
65
|
+
) {
|
|
66
|
+
done(true);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// "+ new custom group" prompt overlay.
|
|
2
|
+
|
|
3
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
type Component,
|
|
6
|
+
getKeybindings,
|
|
7
|
+
Input,
|
|
8
|
+
matchesKey,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
import { withProviderPrefix } from "../compat.ts";
|
|
12
|
+
import { frame, frameInner } from "../ui-frame.ts";
|
|
13
|
+
import type { Theme } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
export async function promptNewProviderName(
|
|
16
|
+
ctx: ExtensionCommandContext,
|
|
17
|
+
prefix: string | undefined,
|
|
18
|
+
): Promise<string | null> {
|
|
19
|
+
const suggestion = withProviderPrefix(prefix, "group");
|
|
20
|
+
return ctx.ui.custom<string | null>(
|
|
21
|
+
(tui, theme, _kb, done) =>
|
|
22
|
+
buildNamePrompt(
|
|
23
|
+
tui as unknown as { requestRender(): void },
|
|
24
|
+
theme as unknown as Theme,
|
|
25
|
+
suggestion,
|
|
26
|
+
done,
|
|
27
|
+
),
|
|
28
|
+
{ overlay: true, overlayOptions: { width: 80, maxHeight: "40%" } },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildNamePrompt(
|
|
33
|
+
tui: { requestRender(): void },
|
|
34
|
+
theme: Theme,
|
|
35
|
+
suggestion: string,
|
|
36
|
+
done: (v: string | null) => void,
|
|
37
|
+
): Component & { handleInput(data: string): void } {
|
|
38
|
+
const input = new Input();
|
|
39
|
+
if (suggestion) input.setValue(suggestion);
|
|
40
|
+
input.focused = true;
|
|
41
|
+
let error: string | null = null;
|
|
42
|
+
|
|
43
|
+
input.onSubmit = (raw) => {
|
|
44
|
+
const v = raw.trim();
|
|
45
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(v)) {
|
|
46
|
+
error = "letters / digits / dot / dash / underscore only";
|
|
47
|
+
tui.requestRender();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
done(v);
|
|
51
|
+
};
|
|
52
|
+
input.onEscape = () => done(null);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
render(width: number): string[] {
|
|
56
|
+
const inner = frameInner(width);
|
|
57
|
+
const hint = suggestion
|
|
58
|
+
? `name shown in /model picker, e.g. ${suggestion}`
|
|
59
|
+
: "letters / digits / dot / dash / underscore";
|
|
60
|
+
const inputLines = input.render(inner - 4);
|
|
61
|
+
const lines: string[] = [
|
|
62
|
+
"",
|
|
63
|
+
` ${theme.fg("dim", hint)}`,
|
|
64
|
+
"",
|
|
65
|
+
...inputLines.map((ln) => ` ${theme.fg("accent", `\u276f ${ln}`)}`),
|
|
66
|
+
"",
|
|
67
|
+
error ? ` ${theme.fg("error", `! ${error}`)}` : "",
|
|
68
|
+
];
|
|
69
|
+
return frame(theme, {
|
|
70
|
+
width,
|
|
71
|
+
title: " new custom group ",
|
|
72
|
+
lines,
|
|
73
|
+
footer: { hint: " enter = create \u00b7 esc = cancel " },
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
invalidate(): void {
|
|
77
|
+
input.invalidate();
|
|
78
|
+
},
|
|
79
|
+
handleInput(data: string): void {
|
|
80
|
+
const kb = getKeybindings();
|
|
81
|
+
if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
|
|
82
|
+
done(null);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
error = null;
|
|
86
|
+
input.handleInput(data);
|
|
87
|
+
tui.requestRender();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Resolve the list of providers shown in the left panel. The union covers:
|
|
2
|
+
// - every built-in provider that the proxy offers AND/OR pi-ai knows
|
|
3
|
+
// about (so a newly-released provider that's not in the local catalog
|
|
4
|
+
// still surfaces);
|
|
5
|
+
// - every custom group already declared in the user's config.
|
|
6
|
+
|
|
7
|
+
import type { Api } from "@earendil-works/pi-ai";
|
|
8
|
+
import { getModels, getProviders } from "@earendil-works/pi-ai";
|
|
9
|
+
|
|
10
|
+
import type { ProxyConfig } from "../config.ts";
|
|
11
|
+
import type { CatalogIndex, ProviderEntry } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
export function collectProviders(
|
|
14
|
+
cfg: ProxyConfig,
|
|
15
|
+
catalog: CatalogIndex,
|
|
16
|
+
): ProviderEntry[] {
|
|
17
|
+
const out: ProviderEntry[] = [];
|
|
18
|
+
|
|
19
|
+
const proxyProviderNames = new Set<string>(catalog.builtinModelIds.keys());
|
|
20
|
+
const knownPiAiProviders = new Set<string>(getProviders());
|
|
21
|
+
const builtinNames = new Set<string>([
|
|
22
|
+
...proxyProviderNames,
|
|
23
|
+
...knownPiAiProviders,
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
for (const name of Array.from(builtinNames).sort()) {
|
|
27
|
+
const proxyIds = catalog.builtinModelIds.get(name);
|
|
28
|
+
if (!proxyIds || proxyIds.length === 0) continue;
|
|
29
|
+
|
|
30
|
+
// Pick the API: pi-ai catalog first, then proxy hint.
|
|
31
|
+
let api: Api | undefined;
|
|
32
|
+
try {
|
|
33
|
+
const localModels = getModels(name as Parameters<typeof getModels>[0]);
|
|
34
|
+
const hit = localModels.find((m) => proxyIds.includes(m.id));
|
|
35
|
+
if (hit) api = hit.api as Api;
|
|
36
|
+
} catch {
|
|
37
|
+
/* unknown provider in pi-ai \u2014 fall back to proxy hint */
|
|
38
|
+
}
|
|
39
|
+
if (!api) {
|
|
40
|
+
const firstId = proxyIds[0]!;
|
|
41
|
+
api = catalog.byId.get(firstId)?.suggestedApi ?? "openai-responses";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
out.push({
|
|
45
|
+
kind: "builtin",
|
|
46
|
+
name,
|
|
47
|
+
api,
|
|
48
|
+
subtitle: `built-in \u00b7 ${api}`,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Make sure config has a slot so state tracking is straightforward.
|
|
52
|
+
if (!cfg.builtinProviders[name]) {
|
|
53
|
+
cfg.builtinProviders[name] = { enabled: false, models: [] };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const slug of Object.keys(cfg.customProviders).sort()) {
|
|
58
|
+
const p = cfg.customProviders[slug]!;
|
|
59
|
+
out.push({
|
|
60
|
+
kind: "custom",
|
|
61
|
+
name: slug,
|
|
62
|
+
api: p.api,
|
|
63
|
+
subtitle: `custom \u00b7 ${p.api}`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Low-level text utilities. Everything here is pure and ANSI-aware.
|
|
2
|
+
|
|
3
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
/** Pad a possibly-ANSI string to exactly `width` cells, truncating if needed. */
|
|
6
|
+
export function pad(s: string, width: number): string {
|
|
7
|
+
const w = visibleWidth(s);
|
|
8
|
+
if (w === width) return s;
|
|
9
|
+
if (w < width) return s + " ".repeat(width - w);
|
|
10
|
+
return truncateAnsi(s, width);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Clip an ANSI-coloured string to `width` visible cells. SGR escapes pass
|
|
15
|
+
* through; a final reset is appended so trailing colour state does not bleed.
|
|
16
|
+
*/
|
|
17
|
+
export function truncateAnsi(s: string, width: number): string {
|
|
18
|
+
if (width <= 0) return "\x1b[0m";
|
|
19
|
+
let out = "";
|
|
20
|
+
let visible = 0;
|
|
21
|
+
let i = 0;
|
|
22
|
+
while (i < s.length) {
|
|
23
|
+
const ch = s[i]!;
|
|
24
|
+
if (ch === "\x1b" && s[i + 1] === "[") {
|
|
25
|
+
const end = s.indexOf("m", i + 2);
|
|
26
|
+
if (end >= 0) {
|
|
27
|
+
out += s.slice(i, end + 1);
|
|
28
|
+
i = end + 1;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const w = visibleWidth(ch);
|
|
33
|
+
if (visible + w > width) break;
|
|
34
|
+
out += ch;
|
|
35
|
+
visible += w;
|
|
36
|
+
i++;
|
|
37
|
+
}
|
|
38
|
+
return `${out}\x1b[0m`;
|
|
39
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Row renderers for left + right panels. Each function returns a string that
|
|
2
|
+
// is guaranteed to fit into `width` visible cells (via marquee on the focused
|
|
3
|
+
// row, and pad/truncate otherwise).
|
|
4
|
+
//
|
|
5
|
+
// The renderers also draw the cursor differently for the active panel
|
|
6
|
+
// (\u25b6 accent) vs an inactive panel where the cursor row is highlighted
|
|
7
|
+
// but more muted (\u00b7 dim) \u2014 this gives users a hint where focus is
|
|
8
|
+
// without losing track of selection across panels.
|
|
9
|
+
|
|
10
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
|
|
12
|
+
import type { ProxyConfig } from "../config.ts";
|
|
13
|
+
import { pad, truncateAnsi } from "./render-text.ts";
|
|
14
|
+
import type { ModelEntry, ProviderEntry, Theme } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
interface RowCtx {
|
|
17
|
+
theme: Theme;
|
|
18
|
+
width: number;
|
|
19
|
+
isCursor: boolean;
|
|
20
|
+
isFocused: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function cursorMark(
|
|
24
|
+
theme: Theme,
|
|
25
|
+
isCursor: boolean,
|
|
26
|
+
isFocused: boolean,
|
|
27
|
+
): string {
|
|
28
|
+
if (!isCursor) return " ";
|
|
29
|
+
if (isFocused) return theme.fg("accent", "\u25b6 ");
|
|
30
|
+
return theme.fg("muted", "\u25b8 ");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fitLine(line: string, width: number): string {
|
|
34
|
+
const w = visibleWidth(line);
|
|
35
|
+
if (w === width) return line;
|
|
36
|
+
if (w > width) return truncateAnsi(line, width);
|
|
37
|
+
return pad(line, width);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --------------------------------------------------------------------------- left panel rows
|
|
41
|
+
|
|
42
|
+
export function renderProviderRow(
|
|
43
|
+
p: ProviderEntry,
|
|
44
|
+
cfg: ProxyConfig,
|
|
45
|
+
ctx: RowCtx,
|
|
46
|
+
): string {
|
|
47
|
+
const { theme, width, isCursor, isFocused } = ctx;
|
|
48
|
+
const mark = cursorMark(theme, isCursor, isFocused);
|
|
49
|
+
const count =
|
|
50
|
+
p.kind === "builtin"
|
|
51
|
+
? (cfg.builtinProviders[p.name]?.models.length ?? 0)
|
|
52
|
+
: (cfg.customProviders[p.name]?.models.length ?? 0);
|
|
53
|
+
|
|
54
|
+
const tag =
|
|
55
|
+
p.kind === "builtin" ? theme.fg("accent", "B") : theme.fg("warning", "C");
|
|
56
|
+
const tagBracket = `${theme.fg("dim", "[")}${tag}${theme.fg("dim", "]")}`;
|
|
57
|
+
|
|
58
|
+
const dot =
|
|
59
|
+
count > 0 ? theme.fg("success", "\u25cf") : theme.fg("dim", "\u25cb");
|
|
60
|
+
const countStr =
|
|
61
|
+
count > 0
|
|
62
|
+
? theme.fg("success", String(count).padStart(2))
|
|
63
|
+
: theme.fg("dim", " 0");
|
|
64
|
+
|
|
65
|
+
const name =
|
|
66
|
+
isCursor && isFocused
|
|
67
|
+
? theme.bold(theme.fg("accent", p.name))
|
|
68
|
+
: isCursor
|
|
69
|
+
? theme.bold(p.name)
|
|
70
|
+
: p.name;
|
|
71
|
+
const api = theme.fg("dim", p.api);
|
|
72
|
+
|
|
73
|
+
const line = `${mark}${tagBracket} ${name} ${dot}${countStr} ${api}`;
|
|
74
|
+
return fitLine(line, width);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderNewProviderRow(
|
|
78
|
+
theme: Theme,
|
|
79
|
+
isCursor: boolean,
|
|
80
|
+
isFocused: boolean,
|
|
81
|
+
width: number,
|
|
82
|
+
): string {
|
|
83
|
+
const mark = cursorMark(theme, isCursor, isFocused);
|
|
84
|
+
const body =
|
|
85
|
+
isCursor && isFocused
|
|
86
|
+
? theme.bold(theme.fg("accent", "\uff0b new custom group\u2026"))
|
|
87
|
+
: theme.fg("accent", "\uff0b new custom group\u2026");
|
|
88
|
+
return pad(`${mark}${body}`, width);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --------------------------------------------------------------------------- right panel rows
|
|
92
|
+
|
|
93
|
+
export function renderModelRow(
|
|
94
|
+
id: string,
|
|
95
|
+
m: ModelEntry | undefined,
|
|
96
|
+
side: "assigned" | "pool",
|
|
97
|
+
compatWarn: boolean,
|
|
98
|
+
ctx: RowCtx,
|
|
99
|
+
): string {
|
|
100
|
+
const { theme, width, isCursor, isFocused } = ctx;
|
|
101
|
+
const mark = cursorMark(theme, isCursor, isFocused);
|
|
102
|
+
|
|
103
|
+
const box =
|
|
104
|
+
side === "assigned"
|
|
105
|
+
? theme.fg("success", "\u2611")
|
|
106
|
+
: theme.fg("dim", "\u2610");
|
|
107
|
+
|
|
108
|
+
const warn = compatWarn ? ` ${theme.fg("warning", "\u26a0")}` : "";
|
|
109
|
+
const idStr = isCursor && isFocused ? theme.fg("accent", id) : id;
|
|
110
|
+
const reasoning = m?.reasoning ? ` ${theme.fg("accent", "\u2728")}` : "";
|
|
111
|
+
const apiTag = m
|
|
112
|
+
? theme.fg("muted", ` \u00b7 ${m.suggestedApi}`)
|
|
113
|
+
: theme.fg("error", " \u00b7 not on proxy");
|
|
114
|
+
|
|
115
|
+
const line = `${mark}${box} ${idStr}${warn}${reasoning}${apiTag}`;
|
|
116
|
+
return fitLine(line, width);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --------------------------------------------------------------------------- panel subheaders
|
|
120
|
+
|
|
121
|
+
export function renderSubheader(
|
|
122
|
+
theme: Theme,
|
|
123
|
+
label: string,
|
|
124
|
+
width: number,
|
|
125
|
+
): string {
|
|
126
|
+
// Compact "── label ──" tag, not a full-width rule. We don't want it to
|
|
127
|
+
// look like another panel frame.
|
|
128
|
+
const tag = `${theme.fg("borderAccent", "\u2500\u2500")} ${theme.fg("warning", label)} ${theme.fg("borderAccent", "\u2500\u2500")}`;
|
|
129
|
+
return pad(` ${tag}`, width);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function renderEmpty(
|
|
133
|
+
theme: Theme,
|
|
134
|
+
label: string,
|
|
135
|
+
width: number,
|
|
136
|
+
): string {
|
|
137
|
+
return pad(` ${theme.fg("dim", label)}`, width);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Decorate a header line ("assigned to <name>") with focus emphasis. */
|
|
141
|
+
export function renderPanelHeader(
|
|
142
|
+
theme: Theme,
|
|
143
|
+
text: string,
|
|
144
|
+
width: number,
|
|
145
|
+
isFocused: boolean,
|
|
146
|
+
): string {
|
|
147
|
+
const prefix = isFocused
|
|
148
|
+
? theme.bold(theme.fg("accent", "\u275a "))
|
|
149
|
+
: theme.fg("dim", " ");
|
|
150
|
+
return pad(`${prefix}${text}`, width);
|
|
151
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Shared types for the /cliproxy three-panel picker.
|
|
2
|
+
|
|
3
|
+
import type { Api } from "@earendil-works/pi-ai";
|
|
4
|
+
|
|
5
|
+
export interface Theme {
|
|
6
|
+
fg(name: string, s: string): string;
|
|
7
|
+
bold(s: string): string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface OverlayTui {
|
|
11
|
+
requestRender(): void;
|
|
12
|
+
rows?: number;
|
|
13
|
+
cols?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type PanelId = "providers" | "assigned" | "pool";
|
|
17
|
+
|
|
18
|
+
export interface ProviderEntry {
|
|
19
|
+
kind: "builtin" | "custom";
|
|
20
|
+
name: string;
|
|
21
|
+
/** API the provider expects. Used for compat warnings on pool rows. */
|
|
22
|
+
api: Api;
|
|
23
|
+
/** Subtitle shown in the providers panel. */
|
|
24
|
+
subtitle: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ModelEntry {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
/** Suggested API for this model (from server hint / catalog). */
|
|
31
|
+
suggestedApi: Api;
|
|
32
|
+
/** Subtitle to render. */
|
|
33
|
+
subtitle?: string;
|
|
34
|
+
/** Origin label for grouping (e.g. "lproxy-glm"); empty for built-in. */
|
|
35
|
+
origin?: string;
|
|
36
|
+
/** Raw upstream owned_by tag, for grouping/UX. */
|
|
37
|
+
ownedBy?: string;
|
|
38
|
+
reasoning: boolean;
|
|
39
|
+
contextWindow: number;
|
|
40
|
+
maxTokens: number;
|
|
41
|
+
cost?: {
|
|
42
|
+
input: number;
|
|
43
|
+
output: number;
|
|
44
|
+
cacheRead: number;
|
|
45
|
+
cacheWrite: number;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CatalogIndex {
|
|
50
|
+
/** All models indexed by id, regardless of provider scope. */
|
|
51
|
+
byId: Map<string, ModelEntry>;
|
|
52
|
+
/** For each built-in provider name, ordered model ids that it offers on the proxy. */
|
|
53
|
+
builtinModelIds: Map<string, string[]>;
|
|
54
|
+
/** Ordered list of all custom-pool model ids. */
|
|
55
|
+
customPoolIds: string[];
|
|
56
|
+
}
|