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/README.md +34 -6
- package/index.ts +1 -1
- package/package.json +1 -1
- package/src/apply.ts +85 -10
- package/src/commands.ts +1 -28
- package/src/ui-frame.ts +118 -0
- package/src/ui-overlay.ts +18 -75
- package/src/ui-picker/catalog.ts +52 -0
- package/src/ui-picker/index.ts +35 -0
- package/src/ui-picker/mutate.ts +133 -0
- package/src/ui-picker/picker-component.ts +432 -0
- package/src/ui-picker/picker.ts +247 -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 +20 -47
- package/src/ui-picker.ts +0 -842
package/README.md
CHANGED
|
@@ -19,13 +19,22 @@ One `(endpoint, apiKey)` pair — every provider and model inherits it automatic
|
|
|
19
19
|
|
|
20
20
|
| Command | Description |
|
|
21
21
|
| --- | --- |
|
|
22
|
-
| `/cliproxy` |
|
|
22
|
+
| `/cliproxy` | Three-panel picker — providers, assigned models, available pool |
|
|
23
23
|
| `/cliproxy-setup` | Configure endpoint, API key, provider prefix, usage key |
|
|
24
24
|
| `/cliproxy-refresh` | Re-fetch upstream models, re-register providers |
|
|
25
|
-
| `/cliproxy-list` | Read-only view of current configuration |
|
|
26
25
|
| `/cliproxy-usage` | Per-account quota windows with progress bars (`d` = show disabled, `v` = verbose) |
|
|
27
26
|
| `/cliproxy-doctor` | Connectivity, key resolution, discovery diagnostics |
|
|
28
27
|
|
|
28
|
+
### `/cliproxy` picker
|
|
29
|
+
|
|
30
|
+
The picker has three panels you cycle through with `Tab` / arrow keys:
|
|
31
|
+
|
|
32
|
+
- **left** — every provider (built-in + custom). `+ new custom group…` is the last row.
|
|
33
|
+
- **right top** — models currently assigned to the focused provider. `Enter` / `Space` removes one.
|
|
34
|
+
- **right bottom** — available model pool, grouped by upstream `owned_by`. `Enter` / `Space` attaches a model to the focused provider.
|
|
35
|
+
|
|
36
|
+
Extra keys: `d` removes a custom group (with confirmation), `s` saves, `q` / `Esc` cancels. A `⚠` marker shows when a model's recommended API differs from the provider's API — attach is allowed anyway.
|
|
37
|
+
|
|
29
38
|
## Prerequisites
|
|
30
39
|
|
|
31
40
|
You need a running [CliProxyAPI](https://github.com/router-for-me/CLIProxyAPI) instance — this is the corporate LLM proxy that aggregates multiple providers behind a single OpenAI-compatible endpoint.
|
|
@@ -136,7 +145,14 @@ Run `/cliproxy-setup` in Pi and enter:
|
|
|
136
145
|
- **providerPrefix** — short slug for custom provider names (e.g. `corp`, `myproxy`)
|
|
137
146
|
- **usageKey** — same value as `PI_PLUGIN_USAGE_KEY` above (enables `/cliproxy-usage`)
|
|
138
147
|
|
|
139
|
-
The sidecar is **optional** — the plugin
|
|
148
|
+
The sidecar is **optional for basic usage** — without it the plugin falls back to raw `/v1/models` with local heuristics. What changes:
|
|
149
|
+
|
|
150
|
+
| | With sidecar | Without sidecar |
|
|
151
|
+
| --- | --- | --- |
|
|
152
|
+
| Model discovery | Enriched from [models.dev](https://models.dev) (real context windows, costs, reasoning) | Defaults: `contextWindow=128k`, `maxTokens=16k`, `cost=0`, `reasoning=false` |
|
|
153
|
+
| `/cliproxy-usage` | Works — per-account quota bars | **Does not work** (no `/api/usage` endpoint) |
|
|
154
|
+
| Classification | Server-side, accurate | Local heuristics by `owned_by` |
|
|
155
|
+
| `/cliproxy`, `/cliproxy-doctor` | Work | Work |
|
|
140
156
|
|
|
141
157
|
## Layout
|
|
142
158
|
|
|
@@ -144,15 +160,27 @@ The sidecar is **optional** — the plugin works without it using `/v1/models` +
|
|
|
144
160
|
index.ts ExtensionFactory entry point
|
|
145
161
|
src/
|
|
146
162
|
config.ts ~/.config/pi-cliproxyapi/config.json
|
|
147
|
-
commands.ts
|
|
163
|
+
commands.ts 5 slash commands
|
|
148
164
|
apply.ts pi.registerProvider calls
|
|
149
165
|
fetch-models.ts well-known + /v1/models fallback
|
|
150
166
|
fetch-usage.ts /api/usage client with TTL cache
|
|
151
167
|
compat.ts baseUrl derivation, model classification
|
|
152
168
|
conflicts.ts read-only ~/.pi/{models,auth}.json scan
|
|
153
|
-
ui-
|
|
154
|
-
ui-usage.ts ANSI-colored usage renderer
|
|
169
|
+
ui-frame.ts single source of truth for overlay frames
|
|
155
170
|
ui-overlay.ts scrollable overlay shell with toggles
|
|
156
171
|
ui-setup.ts setup wizard
|
|
172
|
+
ui-usage.ts ANSI-coloured usage renderer
|
|
173
|
+
ui-picker/ three-panel /cliproxy overlay
|
|
174
|
+
index.ts public runPicker entry
|
|
175
|
+
types.ts shared TS types
|
|
176
|
+
catalog.ts build a model lookup from discovery
|
|
177
|
+
providers.ts resolve the providers shown in the left panel
|
|
178
|
+
mutate.ts attach / detach / claim helpers + pool grouping
|
|
179
|
+
render-text.ts ANSI-aware pad / truncate
|
|
180
|
+
rows.ts per-row renderers for left / right panels
|
|
181
|
+
picker.ts state + navigation, glues catalogue to UI
|
|
182
|
+
picker-component.ts render + input dispatch
|
|
183
|
+
prompt-confirm.ts remove-group confirmation
|
|
184
|
+
prompt-name.ts new-group name prompt
|
|
157
185
|
log.ts tagged logger
|
|
158
186
|
```
|
package/index.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 2. fetch discovery (well-known → fall back to /v1/models)
|
|
8
8
|
* 3. call pi.registerProvider for each enabled built-in + custom provider
|
|
9
9
|
* 4. register slash commands /cliproxy /cliproxy-setup /cliproxy-refresh
|
|
10
|
-
* /cliproxy-
|
|
10
|
+
* /cliproxy-usage /cliproxy-doctor
|
|
11
11
|
*
|
|
12
12
|
* All discovery + apply errors are logged but never abort extension load —
|
|
13
13
|
* a missing/broken proxy must not prevent Pi from starting.
|
package/package.json
CHANGED
package/src/apply.ts
CHANGED
|
@@ -37,6 +37,36 @@ export async function applyAll(
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// -------- builtin providers (anthropic, openai, etc.)
|
|
40
|
+
const builtinByName = new Map<
|
|
41
|
+
string,
|
|
42
|
+
Array<{
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
reasoning: boolean;
|
|
46
|
+
contextWindow: number;
|
|
47
|
+
maxTokens: number;
|
|
48
|
+
cost: {
|
|
49
|
+
input: number;
|
|
50
|
+
output: number;
|
|
51
|
+
cacheRead: number;
|
|
52
|
+
cacheWrite: number;
|
|
53
|
+
};
|
|
54
|
+
}>
|
|
55
|
+
>();
|
|
56
|
+
for (const p of discovery.builtinProviders) {
|
|
57
|
+
builtinByName.set(
|
|
58
|
+
p.name,
|
|
59
|
+
p.models.map((m) => ({
|
|
60
|
+
id: m.id,
|
|
61
|
+
name: m.name,
|
|
62
|
+
reasoning: m.reasoning,
|
|
63
|
+
contextWindow: m.contextWindow,
|
|
64
|
+
maxTokens: m.maxTokens,
|
|
65
|
+
cost: m.cost,
|
|
66
|
+
})),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
40
70
|
for (const [name, p] of Object.entries(cfg.builtinProviders)) {
|
|
41
71
|
if (!p?.enabled) {
|
|
42
72
|
report.skipped.push({ provider: name, reason: "disabled" });
|
|
@@ -46,7 +76,7 @@ export async function applyAll(
|
|
|
46
76
|
report.skipped.push({ provider: name, reason: "empty whitelist" });
|
|
47
77
|
continue;
|
|
48
78
|
}
|
|
49
|
-
let
|
|
79
|
+
let catalog: ReadonlyArray<{
|
|
50
80
|
id: string;
|
|
51
81
|
name: string;
|
|
52
82
|
api: Api;
|
|
@@ -56,19 +86,64 @@ export async function applyAll(
|
|
|
56
86
|
contextWindow: number;
|
|
57
87
|
maxTokens: number;
|
|
58
88
|
thinkingLevelMap?: any;
|
|
59
|
-
}
|
|
89
|
+
}> = [];
|
|
60
90
|
try {
|
|
61
|
-
|
|
91
|
+
catalog = getModels(name as any) as any;
|
|
62
92
|
} catch {
|
|
63
|
-
|
|
64
|
-
provider: name,
|
|
65
|
-
reason: `pi-ai has no provider "${name}"`,
|
|
66
|
-
});
|
|
67
|
-
continue;
|
|
93
|
+
/* unknown provider in pi-ai catalog — keep going with proxy data */
|
|
68
94
|
}
|
|
69
|
-
const
|
|
70
|
-
|
|
95
|
+
const catalogById = new Map(catalog.map((m) => [m.id, m]));
|
|
96
|
+
const proxyById = new Map(
|
|
97
|
+
(builtinByName.get(name) ?? []).map((m) => [m.id, m]),
|
|
71
98
|
);
|
|
99
|
+
|
|
100
|
+
interface MergedModel {
|
|
101
|
+
id: string;
|
|
102
|
+
name: string;
|
|
103
|
+
reasoning: boolean;
|
|
104
|
+
contextWindow: number;
|
|
105
|
+
maxTokens: number;
|
|
106
|
+
cost: any;
|
|
107
|
+
input: ("text" | "image")[];
|
|
108
|
+
api: Api;
|
|
109
|
+
thinkingLevelMap?: any;
|
|
110
|
+
}
|
|
111
|
+
const selected: MergedModel[] = [];
|
|
112
|
+
for (const id of p.models) {
|
|
113
|
+
if (!proxyIds.has(id)) continue;
|
|
114
|
+
const c = catalogById.get(id);
|
|
115
|
+
const px = proxyById.get(id);
|
|
116
|
+
if (c) {
|
|
117
|
+
selected.push({
|
|
118
|
+
id: c.id,
|
|
119
|
+
name: c.name,
|
|
120
|
+
reasoning: c.reasoning,
|
|
121
|
+
contextWindow: c.contextWindow,
|
|
122
|
+
maxTokens: c.maxTokens,
|
|
123
|
+
cost: c.cost,
|
|
124
|
+
input: c.input,
|
|
125
|
+
api: c.api,
|
|
126
|
+
thinkingLevelMap: c.thinkingLevelMap,
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!px) continue;
|
|
131
|
+
// Catalog miss — fall back to proxy metadata. Pick API by provider name.
|
|
132
|
+
const api: Api =
|
|
133
|
+
name === "anthropic"
|
|
134
|
+
? "anthropic-messages"
|
|
135
|
+
: ((p.apiOverride as Api | undefined) ?? "openai-responses");
|
|
136
|
+
selected.push({
|
|
137
|
+
id: px.id,
|
|
138
|
+
name: px.name,
|
|
139
|
+
reasoning: px.reasoning,
|
|
140
|
+
contextWindow: px.contextWindow,
|
|
141
|
+
maxTokens: px.maxTokens,
|
|
142
|
+
cost: px.cost,
|
|
143
|
+
input: ["text"],
|
|
144
|
+
api,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
72
147
|
if (selected.length === 0) {
|
|
73
148
|
report.skipped.push({
|
|
74
149
|
provider: name,
|
package/src/commands.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// /cliproxy — open the picker overlay
|
|
3
3
|
// /cliproxy-setup — first-run / re-run setup wizard for endpoint+keys
|
|
4
4
|
// /cliproxy-refresh — refetch discovery + re-apply
|
|
5
|
-
// /cliproxy-list — show all upstream models in an overlay
|
|
6
5
|
// /cliproxy-usage — fetch /api/usage and render in overlay
|
|
7
6
|
// /cliproxy-doctor — connectivity + key-resolution diagnostics
|
|
8
7
|
|
|
@@ -18,7 +17,7 @@ import { fetchDiscovery, PLUGIN_USER_AGENT } from "./fetch-models.ts";
|
|
|
18
17
|
import { clearUsageCache, fetchUsage } from "./fetch-usage.ts";
|
|
19
18
|
import { log } from "./log.ts";
|
|
20
19
|
import { showOverlay } from "./ui-overlay.ts";
|
|
21
|
-
import { runPicker } from "./ui-picker.ts";
|
|
20
|
+
import { runPicker } from "./ui-picker/index.ts";
|
|
22
21
|
import { runSetup } from "./ui-setup.ts";
|
|
23
22
|
import { renderUsage } from "./ui-usage.ts";
|
|
24
23
|
|
|
@@ -41,11 +40,6 @@ export function registerCommands(pi: ExtensionAPI): void {
|
|
|
41
40
|
handler: handleRefresh.bind(null, pi),
|
|
42
41
|
});
|
|
43
42
|
|
|
44
|
-
pi.registerCommand("cliproxy-list", {
|
|
45
|
-
description: "Show every upstream model in a scrollable overlay",
|
|
46
|
-
handler: handleList,
|
|
47
|
-
});
|
|
48
|
-
|
|
49
43
|
pi.registerCommand("cliproxy-usage", {
|
|
50
44
|
description: "Show per-account quota windows from the upstream",
|
|
51
45
|
handler: handleUsage,
|
|
@@ -147,27 +141,6 @@ async function handleRefresh(
|
|
|
147
141
|
}
|
|
148
142
|
}
|
|
149
143
|
|
|
150
|
-
// --------------------------------------------------------------------------- /cliproxy-list
|
|
151
|
-
|
|
152
|
-
async function handleList(
|
|
153
|
-
_args: string,
|
|
154
|
-
ctx: ExtensionCommandContext,
|
|
155
|
-
): Promise<void> {
|
|
156
|
-
const cfg = loadConfig();
|
|
157
|
-
const resolvedKey = resolveConfigValue(cfg.proxy.apiKey);
|
|
158
|
-
let discovery;
|
|
159
|
-
try {
|
|
160
|
-
discovery = await fetchDiscovery(cfg, resolvedKey);
|
|
161
|
-
} catch (err) {
|
|
162
|
-
ctx.ui.notify(`list failed: ${(err as Error).message}`, "error");
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
await runPicker(ctx, cfg, discovery, {
|
|
166
|
-
readOnly: true,
|
|
167
|
-
title: " /cliproxy-list \u00b7 read-only ",
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
|
|
171
144
|
// --------------------------------------------------------------------------- /cliproxy-usage
|
|
172
145
|
|
|
173
146
|
async function handleUsage(
|
package/src/ui-frame.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Shared frame renderer for every /cliproxy overlay.
|
|
2
|
+
//
|
|
3
|
+
// Geometry contract:
|
|
4
|
+
// - frame() always returns lines that are EXACTLY `width` visible cells.
|
|
5
|
+
// - top = \u256d\u2500 <title> <fill> \u256e
|
|
6
|
+
// - body i = \u2502 <line padded/truncated to width-2> \u2502
|
|
7
|
+
// - footer = \u2570\u2500 <hint> <fill> [badge] \u256f (if footer provided)
|
|
8
|
+
// or \u2570\u2500\u2500\u2500\u2026\u2500\u2500\u2500\u256f (otherwise)
|
|
9
|
+
//
|
|
10
|
+
// Callers stay simple:
|
|
11
|
+
//
|
|
12
|
+
// return frame(theme, {
|
|
13
|
+
// width,
|
|
14
|
+
// title: " setup ",
|
|
15
|
+
// lines: [
|
|
16
|
+
// " hint goes here ",
|
|
17
|
+
// "",
|
|
18
|
+
// theme.fg("accent", "> input"),
|
|
19
|
+
// ],
|
|
20
|
+
// footer: { hint: "enter = save \u00b7 esc = cancel" },
|
|
21
|
+
// });
|
|
22
|
+
//
|
|
23
|
+
// Anything inside `lines` may already contain ANSI escapes; pad() is ANSI-aware
|
|
24
|
+
// (see render-text.ts). No caller should ever draw \u256d/\u256e/\u2502/\u2570/\u256f by hand again.
|
|
25
|
+
|
|
26
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
27
|
+
|
|
28
|
+
import { pad } from "./ui-picker/render-text.ts";
|
|
29
|
+
import type { Theme } from "./ui-picker/types.ts";
|
|
30
|
+
|
|
31
|
+
export interface FrameFooter {
|
|
32
|
+
/** Left-aligned hint text (dimmed). */
|
|
33
|
+
hint?: string;
|
|
34
|
+
/** Right-aligned focus/state badge (muted). */
|
|
35
|
+
badge?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FrameOpts {
|
|
39
|
+
width: number;
|
|
40
|
+
title?: string;
|
|
41
|
+
titleColor?: "accent" | "error" | "success" | "warning";
|
|
42
|
+
lines: string[];
|
|
43
|
+
footer?: FrameFooter;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SIDE = "\u2502";
|
|
47
|
+
const TL = "\u256d";
|
|
48
|
+
const TR = "\u256e";
|
|
49
|
+
const BL = "\u2570";
|
|
50
|
+
const BR = "\u256f";
|
|
51
|
+
const HR = "\u2500";
|
|
52
|
+
|
|
53
|
+
export function frame(theme: Theme, opts: FrameOpts): string[] {
|
|
54
|
+
const w = Math.max(4, opts.width);
|
|
55
|
+
return [
|
|
56
|
+
drawTop(theme, opts.title, opts.titleColor ?? "accent", w),
|
|
57
|
+
...opts.lines.map((line) => drawBody(theme, line, w)),
|
|
58
|
+
drawBottom(theme, opts.footer, w),
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function drawTop(
|
|
63
|
+
theme: Theme,
|
|
64
|
+
title: string | undefined,
|
|
65
|
+
color: "accent" | "error" | "success" | "warning",
|
|
66
|
+
width: number,
|
|
67
|
+
): string {
|
|
68
|
+
// Layout: \u256d \u2500 <title> <fill> \u256e \u2014 exactly `width` cells.
|
|
69
|
+
// Fixed cells = 2 (\u256d\u2500) + 1 (\u256e) = 3. The rest is title + fill.
|
|
70
|
+
const t = title ?? "";
|
|
71
|
+
const titleVis = visibleWidth(t);
|
|
72
|
+
const rest = Math.max(0, width - 3 - titleVis);
|
|
73
|
+
const titleColoured = t ? theme.bold(theme.fg(color, t)) : "";
|
|
74
|
+
const body = `${TL}${HR}${titleColoured}${HR.repeat(rest)}${TR}`;
|
|
75
|
+
return theme.fg("borderAccent", body);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function drawBody(theme: Theme, content: string, width: number): string {
|
|
79
|
+
// Layout: \u2502 <content padded to width-2> \u2502
|
|
80
|
+
const inner = Math.max(0, width - 2);
|
|
81
|
+
const side = theme.fg("borderAccent", SIDE);
|
|
82
|
+
return `${side}${pad(content, inner)}${side}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function drawBottom(
|
|
86
|
+
theme: Theme,
|
|
87
|
+
footer: FrameFooter | undefined,
|
|
88
|
+
width: number,
|
|
89
|
+
): string {
|
|
90
|
+
const start = `${BL}${HR}`;
|
|
91
|
+
const end = `${BR}`;
|
|
92
|
+
const fixedCells = 3; // \u2570 + \u2500 + \u256f
|
|
93
|
+
|
|
94
|
+
if (!footer || (!footer.hint && !footer.badge)) {
|
|
95
|
+
const rest = Math.max(0, width - fixedCells);
|
|
96
|
+
return theme.fg("borderAccent", `${start}${HR.repeat(rest)}${end}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const hint = footer.hint ?? "";
|
|
100
|
+
const badge = footer.badge ?? "";
|
|
101
|
+
const hintVis = visibleWidth(hint);
|
|
102
|
+
const badgeVis = visibleWidth(badge);
|
|
103
|
+
const rest = Math.max(0, width - fixedCells - hintVis - badgeVis);
|
|
104
|
+
|
|
105
|
+
const left = theme.fg("dim", hint);
|
|
106
|
+
const right = badge ? theme.fg("muted", badge) : "";
|
|
107
|
+
const fill = HR.repeat(rest);
|
|
108
|
+
|
|
109
|
+
return `${theme.fg("borderAccent", start)}${left}${theme.fg("borderAccent", fill)}${right}${theme.fg("borderAccent", end)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Convenience: number of usable content cells per body line for a given frame
|
|
114
|
+
* width. Use this when laying out children that need to know their width.
|
|
115
|
+
*/
|
|
116
|
+
export function frameInner(width: number): number {
|
|
117
|
+
return Math.max(0, width - 2);
|
|
118
|
+
}
|
package/src/ui-overlay.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Read-only scrollable overlay used by /cliproxy-
|
|
1
|
+
// Read-only scrollable overlay used by /cliproxy-usage and /cliproxy-doctor.
|
|
2
2
|
//
|
|
3
3
|
// Pure presentational — never touches the agent session or context. The
|
|
4
4
|
// caller may supply boolean "toggles" (one keystroke each) that re-render the
|
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
visibleWidth,
|
|
24
24
|
} from "@earendil-works/pi-tui";
|
|
25
25
|
|
|
26
|
+
import { frame } from "./ui-frame.ts";
|
|
27
|
+
|
|
26
28
|
interface Theme {
|
|
27
29
|
fg(name: string, s: string): string;
|
|
28
30
|
bold(s: string): string;
|
|
@@ -76,7 +78,6 @@ function buildOverlay(
|
|
|
76
78
|
};
|
|
77
79
|
|
|
78
80
|
const renderFrame = (width: number, height: number): string[] => {
|
|
79
|
-
const inner = Math.max(10, width - 2);
|
|
80
81
|
const visible = Math.max(1, height - 2);
|
|
81
82
|
const total = lines.length;
|
|
82
83
|
const maxOffset = Math.max(0, total - visible);
|
|
@@ -87,26 +88,22 @@ function buildOverlay(
|
|
|
87
88
|
total > visible
|
|
88
89
|
? Math.min(100, Math.round(((offset + visible) / total) * 100))
|
|
89
90
|
: 100;
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
91
|
+
const hintBase = "\u2191\u2193 \u00b7 pgUp/pgDn \u00b7 g/G";
|
|
92
|
+
const togglesText = (props.toggles ?? [])
|
|
93
|
+
.map((tg) =>
|
|
94
|
+
state[tg.key] === true
|
|
95
|
+
? t.fg("success", `[${tg.hint}]`)
|
|
96
|
+
: t.fg("dim", tg.hint),
|
|
97
|
+
)
|
|
98
|
+
.join(" \u00b7 ");
|
|
99
|
+
const hint = ` ${hintBase}${togglesText ? " " + togglesText : ""} `;
|
|
100
|
+
const badge = ` ${offset + 1}\u2013${Math.min(total, offset + visible)} of ${total} ${pct}% `;
|
|
101
|
+
return frame(t, {
|
|
102
|
+
width,
|
|
103
|
+
title: ` ${props.title} `,
|
|
104
|
+
lines: slice.map((ln) => ` ${ln}`),
|
|
105
|
+
footer: { hint, badge },
|
|
101
106
|
});
|
|
102
|
-
const sideL = t.fg("borderAccent", "│");
|
|
103
|
-
const sideR = t.fg("borderAccent", "│");
|
|
104
|
-
const out: string[] = [titleBar];
|
|
105
|
-
for (const ln of slice) {
|
|
106
|
-
out.push(`${sideL} ${padRight(ln, inner - 2)} ${sideR}`);
|
|
107
|
-
}
|
|
108
|
-
out.push(footerBar);
|
|
109
|
-
return out;
|
|
110
107
|
};
|
|
111
108
|
|
|
112
109
|
return {
|
|
@@ -174,60 +171,6 @@ function buildOverlay(
|
|
|
174
171
|
};
|
|
175
172
|
}
|
|
176
173
|
|
|
177
|
-
// --------------------------------------------------------------------------- chrome helpers
|
|
178
|
-
|
|
179
|
-
function formatTitleBar(theme: Theme, title: string, inner: number): string {
|
|
180
|
-
const label = ` ${title} `;
|
|
181
|
-
const leftSep = "╭─";
|
|
182
|
-
const rightFill = inner - visibleWidth(label) - visibleWidth(leftSep) - 2;
|
|
183
|
-
const fill = "─".repeat(Math.max(0, rightFill));
|
|
184
|
-
return theme.fg(
|
|
185
|
-
"borderAccent",
|
|
186
|
-
`${leftSep}${theme.bold(theme.fg("accent", label))}${theme.fg("borderAccent", fill)}╮`,
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function formatFooterBar(
|
|
191
|
-
theme: Theme,
|
|
192
|
-
opts: {
|
|
193
|
-
from: number;
|
|
194
|
-
to: number;
|
|
195
|
-
total: number;
|
|
196
|
-
pct: number;
|
|
197
|
-
width: number;
|
|
198
|
-
toggles: Array<{ hint: string; active: boolean }>;
|
|
199
|
-
},
|
|
200
|
-
): string {
|
|
201
|
-
const hintBase = "↑↓ · pgUp/pgDn · g/G";
|
|
202
|
-
const togglesText = opts.toggles
|
|
203
|
-
.map((tg) =>
|
|
204
|
-
tg.active
|
|
205
|
-
? theme.fg("success", `[${tg.hint}]`)
|
|
206
|
-
: theme.fg("dim", tg.hint),
|
|
207
|
-
)
|
|
208
|
-
.join(" · ");
|
|
209
|
-
const left = ` ${hintBase}${togglesText ? " " + togglesText : ""} `;
|
|
210
|
-
const right = ` ${opts.from}–${opts.to} of ${opts.total} ${opts.pct}% `;
|
|
211
|
-
const leftSep = "╰─";
|
|
212
|
-
const rightSep = "╯";
|
|
213
|
-
const used =
|
|
214
|
-
visibleWidth(leftSep) +
|
|
215
|
-
visibleWidth(rightSep) +
|
|
216
|
-
visibleWidth(left) +
|
|
217
|
-
visibleWidth(right);
|
|
218
|
-
const filler = "─".repeat(Math.max(0, opts.width - used));
|
|
219
|
-
return theme.fg(
|
|
220
|
-
"borderAccent",
|
|
221
|
-
`${leftSep}${theme.fg("dim", left)}${filler}${theme.fg("muted", right)}${rightSep}`,
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function padRight(s: string, width: number): string {
|
|
226
|
-
const w = visibleWidth(s);
|
|
227
|
-
if (w >= width) return s;
|
|
228
|
-
return s + " ".repeat(width - w);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
174
|
// --------------------------------------------------------------------------- public API
|
|
232
175
|
|
|
233
176
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Public entry for the /cliproxy three-panel picker.
|
|
2
|
+
|
|
3
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import type { ProxyConfig } from "../config.ts";
|
|
6
|
+
import type { Discovery } from "../fetch-models.ts";
|
|
7
|
+
import { buildPicker } from "./picker.ts";
|
|
8
|
+
import type { OverlayTui, Theme } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
export async function runPicker(
|
|
11
|
+
ctx: ExtensionCommandContext,
|
|
12
|
+
cfg: ProxyConfig,
|
|
13
|
+
discovery: Discovery,
|
|
14
|
+
): Promise<ProxyConfig | null> {
|
|
15
|
+
if (!ctx.hasUI) {
|
|
16
|
+
ctx.ui.notify("interactive UI required for /cliproxy", "warning");
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const draft: ProxyConfig = structuredClone(cfg);
|
|
20
|
+
return ctx.ui.custom<ProxyConfig | null>(
|
|
21
|
+
(tui, theme, _kb, done) =>
|
|
22
|
+
buildPicker(
|
|
23
|
+
tui as unknown as OverlayTui,
|
|
24
|
+
theme as unknown as Theme,
|
|
25
|
+
draft,
|
|
26
|
+
discovery,
|
|
27
|
+
ctx,
|
|
28
|
+
done,
|
|
29
|
+
),
|
|
30
|
+
{
|
|
31
|
+
overlay: true,
|
|
32
|
+
overlayOptions: { width: 170, maxHeight: "94%" },
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
}
|