pi-cliproxyapi 0.1.2 → 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 +27 -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.
|
|
@@ -143,7 +152,7 @@ The sidecar is **optional for basic usage** — without it the plugin falls back
|
|
|
143
152
|
| Model discovery | Enriched from [models.dev](https://models.dev) (real context windows, costs, reasoning) | Defaults: `contextWindow=128k`, `maxTokens=16k`, `cost=0`, `reasoning=false` |
|
|
144
153
|
| `/cliproxy-usage` | Works — per-account quota bars | **Does not work** (no `/api/usage` endpoint) |
|
|
145
154
|
| Classification | Server-side, accurate | Local heuristics by `owned_by` |
|
|
146
|
-
| `/cliproxy`, `/cliproxy-
|
|
155
|
+
| `/cliproxy`, `/cliproxy-doctor` | Work | Work |
|
|
147
156
|
|
|
148
157
|
## Layout
|
|
149
158
|
|
|
@@ -151,15 +160,27 @@ The sidecar is **optional for basic usage** — without it the plugin falls back
|
|
|
151
160
|
index.ts ExtensionFactory entry point
|
|
152
161
|
src/
|
|
153
162
|
config.ts ~/.config/pi-cliproxyapi/config.json
|
|
154
|
-
commands.ts
|
|
163
|
+
commands.ts 5 slash commands
|
|
155
164
|
apply.ts pi.registerProvider calls
|
|
156
165
|
fetch-models.ts well-known + /v1/models fallback
|
|
157
166
|
fetch-usage.ts /api/usage client with TTL cache
|
|
158
167
|
compat.ts baseUrl derivation, model classification
|
|
159
168
|
conflicts.ts read-only ~/.pi/{models,auth}.json scan
|
|
160
|
-
ui-
|
|
161
|
-
ui-usage.ts ANSI-colored usage renderer
|
|
169
|
+
ui-frame.ts single source of truth for overlay frames
|
|
162
170
|
ui-overlay.ts scrollable overlay shell with toggles
|
|
163
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
|
|
164
185
|
log.ts tagged logger
|
|
165
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
|
+
}
|