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 CHANGED
@@ -9,28 +9,44 @@ One `(endpoint, apiKey)` pair — every provider and model inherits it automatic
9
9
 
10
10
  ## Features
11
11
 
12
+ - **Unified hub** — one `/cliproxy` overlay with **Models / Usage / Diagnostics** tabs (number hotkeys `1` `2` `3`) plus global actions: `r` refresh, `e` setup, `s` save
12
13
  - **Built-in provider routing** — whitelist which Anthropic / OpenAI / etc. models are available through the proxy
13
14
  - **Custom provider groups** — create named groups (e.g. `corp-glm`, `corp-gemini`) for proxy-only models with automatic metadata from [models.dev](https://models.dev)
14
- - **Exclusive model pool** — a model assigned to one group automatically disappears from others
15
- - **Per-account usage overlay** — colored quota bars, toggle disabled accounts, verbose errors no LLM call
15
+ - **Exclusive model pool** — a model assigned to one group automatically disappears from others, grouped by `owned_by` with type-to-filter (`/`)
16
+ - **Live save state** — the header shows `● unsaved` while you edit and `✓ settings saved` after `s`, no console noise
17
+ - **Per-account usage tab** — colored quota bars, toggle disabled accounts, verbose errors — no LLM call
16
18
  - **Setup wizard** — `/cliproxy-setup` configures endpoint, API key, provider prefix, and usage key interactively
17
19
 
18
20
  ## Commands
19
21
 
22
+ Two commands; everything else lives inside the hub as tabs and actions.
23
+
20
24
  | Command | Description |
21
25
  | --- | --- |
22
- | `/cliproxy` | Interactive overlay — enable providers, toggle models, create custom groups |
26
+ | `/cliproxy` | Hub overlay — **Models** / **Usage** / **Diagnostics** tabs plus global actions |
23
27
  | `/cliproxy-setup` | Configure endpoint, API key, provider prefix, usage key |
24
- | `/cliproxy-refresh` | Re-fetch upstream models, re-register providers |
25
- | `/cliproxy-list` | Read-only view of current configuration |
26
- | `/cliproxy-usage` | Per-account quota windows with progress bars (`d` = show disabled, `v` = verbose) |
27
- | `/cliproxy-doctor` | Connectivity, key resolution, discovery diagnostics |
28
+
29
+ ### The `/cliproxy` hub
30
+
31
+ Global keys: `[` / `]` or `1` `2` `3` switch tabs · `r` refresh discovery + reapply · `e` setup · `s` save · `q` / `Esc` close.
32
+
33
+ **Models tab** — three panels cycled with `Tab` / arrows:
34
+
35
+ - **left** — every provider (built-in + custom). `+ new custom group…` is the last row.
36
+ - **right top** — models assigned to the focused provider. `Enter` / `Space` removes one.
37
+ - **right bottom** — available pool, grouped by upstream `owned_by`. `Enter` / `Space` attaches. Press `/` to filter the pool by id/name. A `⚠` marks an API mismatch (attach still allowed).
38
+
39
+ Extra Models keys: `d` removes a custom group (with confirmation).
40
+
41
+ **Usage tab** — per-account quota bars; `d` shows disabled accounts, `v` shows verbose errors.
42
+
43
+ **Diagnostics tab** — connectivity, key resolution, and discovery shape.
28
44
 
29
45
  ## Prerequisites
30
46
 
31
47
  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.
32
48
 
33
- For full functionality (`/cliproxy-usage`, enriched model metadata from [models.dev](https://models.dev)), also deploy the companion sidecar: **[pi-cliproxyapi-wellknown](https://github.com/abix5/pi-cliproxyapi-wellknown)**. See [Deploying the sidecar](#deploying-the-sidecar-service) below.
49
+ For full functionality (Usage tab, enriched model metadata from [models.dev](https://models.dev)), also deploy the companion sidecar: **[pi-cliproxyapi-wellknown](https://github.com/abix5/pi-cliproxyapi-wellknown)**. See [Deploying the sidecar](#deploying-the-sidecar-service) below.
34
50
 
35
51
  ## Install
36
52
 
@@ -76,7 +92,7 @@ The plugin tries `GET <endpoint-origin>/.well-known/pi` first (requires the side
76
92
  The **[pi-cliproxyapi-wellknown](https://github.com/abix5/pi-cliproxyapi-wellknown)** sidecar runs alongside CliProxyAPI and provides:
77
93
 
78
94
  - `/.well-known/pi` — model discovery with metadata from [models.dev](https://models.dev) (context windows, costs, reasoning flags)
79
- - `/api/usage` — per-account quota windows used by `/cliproxy-usage`
95
+ - `/api/usage` — per-account quota windows used by the hub Usage tab
80
96
 
81
97
  ```
82
98
  ┌──────────────┐ ┌───────────────────────────┐
@@ -134,16 +150,16 @@ Run `/cliproxy-setup` in Pi and enter:
134
150
  - **endpoint** — your public proxy URL ending with `/v1`
135
151
  - **apiKey** — CliProxyAPI bearer key
136
152
  - **providerPrefix** — short slug for custom provider names (e.g. `corp`, `myproxy`)
137
- - **usageKey** — same value as `PI_PLUGIN_USAGE_KEY` above (enables `/cliproxy-usage`)
153
+ - **usageKey** — same value as `PI_PLUGIN_USAGE_KEY` above (enables the Usage tab)
138
154
 
139
155
  The sidecar is **optional for basic usage** — without it the plugin falls back to raw `/v1/models` with local heuristics. What changes:
140
156
 
141
157
  | | With sidecar | Without sidecar |
142
158
  | --- | --- | --- |
143
159
  | Model discovery | Enriched from [models.dev](https://models.dev) (real context windows, costs, reasoning) | Defaults: `contextWindow=128k`, `maxTokens=16k`, `cost=0`, `reasoning=false` |
144
- | `/cliproxy-usage` | Works — per-account quota bars | **Does not work** (no `/api/usage` endpoint) |
160
+ | Usage tab | Works — per-account quota bars | **Does not work** (no `/api/usage` endpoint) |
145
161
  | Classification | Server-side, accurate | Local heuristics by `owned_by` |
146
- | `/cliproxy`, `/cliproxy-list`, `/cliproxy-doctor` | Work | Work |
162
+ | `/cliproxy` hub | Works | Works (Usage tab shows an error) |
147
163
 
148
164
  ## Layout
149
165
 
@@ -151,15 +167,31 @@ The sidecar is **optional for basic usage** — without it the plugin falls back
151
167
  index.ts ExtensionFactory entry point
152
168
  src/
153
169
  config.ts ~/.config/pi-cliproxyapi/config.json
154
- commands.ts 6 slash commands
170
+ commands.ts 2 slash commands (hub + setup)
155
171
  apply.ts pi.registerProvider calls
156
172
  fetch-models.ts well-known + /v1/models fallback
157
173
  fetch-usage.ts /api/usage client with TTL cache
158
174
  compat.ts baseUrl derivation, model classification
159
175
  conflicts.ts read-only ~/.pi/{models,auth}.json scan
160
- ui-picker.ts overlay picker with collapsible provider groups
161
- ui-usage.ts ANSI-colored usage renderer
162
- ui-overlay.ts scrollable overlay shell with toggles
176
+ ui-frame.ts single source of truth for overlay frames
163
177
  ui-setup.ts setup wizard
178
+ ui-usage.ts ANSI-coloured usage renderer
179
+ ui-hub/ the /cliproxy hub overlay
180
+ index.ts public runHub entry
181
+ hub.ts tabs, status header, global actions
182
+ types.ts HubView contract
183
+ shell.ts tab bar, status header, scroll/slice helpers
184
+ view-models.ts three-panel picker (single pool ordering + filter)
185
+ view-usage.ts usage tab (lazy fetch + d/v toggles)
186
+ view-diagnostics.ts diagnostics tab
187
+ ui-picker/ picker building blocks reused by the Models view
188
+ types.ts shared TS types
189
+ catalog.ts build a model lookup from discovery
190
+ providers.ts resolve the providers shown in the left panel
191
+ mutate.ts attach / detach / claim + pool grouping + display order
192
+ render-text.ts ANSI-aware pad / truncate
193
+ rows.ts per-row renderers for left / right panels
194
+ prompt-confirm.ts remove-group confirmation
195
+ prompt-name.ts new-group name prompt
164
196
  log.ts tagged logger
165
197
  ```
package/index.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  * 1. load ~/.config/pi-cliproxyapi/config.json (defaults if missing)
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
- * 4. register slash commands /cliproxy /cliproxy-setup /cliproxy-refresh
10
- * /cliproxy-list /cliproxy-usage /cliproxy-doctor
9
+ * 4. register slash commands /cliproxy and /cliproxy-setup
10
+ * (refresh, usage, and diagnostics are tabs/actions inside the hub)
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.
@@ -43,7 +43,7 @@ export default async function cliproxyapi(pi: ExtensionAPI): Promise<void> {
43
43
  await applyAll(pi, cfg, discovery);
44
44
  } catch (err) {
45
45
  log.error("initial apply failed:", (err as Error).message);
46
- // Commands stay registered; user can /cliproxy-doctor or /cliproxy-refresh.
46
+ // Commands stay registered; user can open /cliproxy and press r to refresh.
47
47
  }
48
48
 
49
49
  if (cfg.refreshIntervalMinutes > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cliproxyapi",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension for corporate management of model providers via a single CliProxyAPI endpoint",
6
6
  "license": "MIT",
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 builtin: ReadonlyArray<{
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
- builtin = getModels(name as any) as any;
91
+ catalog = getModels(name as any) as any;
62
92
  } catch {
63
- report.skipped.push({
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 selected = builtin.filter(
70
- (m) => p.models.includes(m.id) && proxyIds.has(m.id),
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
@@ -1,10 +1,8 @@
1
- // Slash commands.
2
- // /cliproxy — open the picker overlay
3
- // /cliproxy-setup — first-run / re-run setup wizard for endpoint+keys
4
- // /cliproxy-refresh — refetch discovery + re-apply
5
- // /cliproxy-list — show all upstream models in an overlay
6
- // /cliproxy-usage — fetch /api/usage and render in overlay
7
- // /cliproxy-doctor — connectivity + key-resolution diagnostics
1
+ // Slash commands (2):
2
+ // /cliproxy — open the hub (Models / Usage / Diagnostics + actions)
3
+ // /cliproxy-setup — first-run / re-run wizard for endpoint+keys
4
+ //
5
+ // Refresh, usage, and diagnostics are now actions/tabs inside the hub.
8
6
 
9
7
  import type {
10
8
  ExtensionAPI,
@@ -12,20 +10,16 @@ import type {
12
10
  } from "@earendil-works/pi-coding-agent";
13
11
 
14
12
  import { applyAll } from "./apply.ts";
15
- import { loadConfig, resolveConfigValue, saveConfig } from "./config.ts";
16
- import { detectConflicts } from "./conflicts.ts";
17
- import { fetchDiscovery, PLUGIN_USER_AGENT } from "./fetch-models.ts";
18
- import { clearUsageCache, fetchUsage } from "./fetch-usage.ts";
19
- import { log } from "./log.ts";
20
- import { showOverlay } from "./ui-overlay.ts";
21
- import { runPicker } from "./ui-picker.ts";
13
+ import { loadConfig, resolveConfigValue } from "./config.ts";
14
+ import { fetchDiscovery } from "./fetch-models.ts";
15
+ import { clearUsageCache } from "./fetch-usage.ts";
16
+ import { runHub } from "./ui-hub/index.ts";
22
17
  import { runSetup } from "./ui-setup.ts";
23
- import { renderUsage } from "./ui-usage.ts";
24
18
 
25
19
  export function registerCommands(pi: ExtensionAPI): void {
26
20
  pi.registerCommand("cliproxy", {
27
21
  description:
28
- "Pick which models to expose via the CliProxyAPI corporate proxy",
22
+ "Manage proxy models, usage, and diagnostics in one hub overlay",
29
23
  handler: handleCliproxy.bind(null, pi),
30
24
  });
31
25
 
@@ -34,27 +28,6 @@ export function registerCommands(pi: ExtensionAPI): void {
34
28
  "Set endpoint, API key, and (optional) usage key for the proxy",
35
29
  handler: handleSetup.bind(null, pi),
36
30
  });
37
-
38
- pi.registerCommand("cliproxy-refresh", {
39
- description:
40
- "Re-fetch upstream model list and re-apply provider registrations",
41
- handler: handleRefresh.bind(null, pi),
42
- });
43
-
44
- pi.registerCommand("cliproxy-list", {
45
- description: "Show every upstream model in a scrollable overlay",
46
- handler: handleList,
47
- });
48
-
49
- pi.registerCommand("cliproxy-usage", {
50
- description: "Show per-account quota windows from the upstream",
51
- handler: handleUsage,
52
- });
53
-
54
- pi.registerCommand("cliproxy-doctor", {
55
- description: "Check connectivity, key resolution, and discovery shape",
56
- handler: handleDoctor,
57
- });
58
31
  }
59
32
 
60
33
  // --------------------------------------------------------------------------- /cliproxy
@@ -67,7 +40,7 @@ async function handleCliproxy(
67
40
  const cfg = loadConfig();
68
41
  if (!cfg.proxy.endpoint || !resolveConfigValue(cfg.proxy.apiKey)) {
69
42
  ctx.ui.notify(
70
- "endpoint or API key not set \u2014 launching /cliproxy-setup first",
43
+ "endpoint or API key not set \u2014 launching setup first",
71
44
  "info",
72
45
  );
73
46
  const ok = await runSetup(ctx, true);
@@ -82,17 +55,7 @@ async function handleCliproxy(
82
55
  ctx.ui.notify(`discovery failed: ${(err as Error).message}`, "error");
83
56
  return;
84
57
  }
85
- const updated = await runPicker(ctx, current, discovery);
86
- if (!updated) {
87
- ctx.ui.notify("changes discarded", "info");
88
- return;
89
- }
90
- saveConfig(updated);
91
- const rep = await applyAll(pi, updated, discovery);
92
- ctx.ui.notify(
93
- `saved \u00b7 ${rep.registered.length} providers registered, ${rep.skipped.length} skipped`,
94
- "info",
95
- );
58
+ await runHub(pi, ctx, current, discovery);
96
59
  }
97
60
 
98
61
  // --------------------------------------------------------------------------- /cliproxy-setup
@@ -124,124 +87,3 @@ async function handleSetup(
124
87
  );
125
88
  }
126
89
  }
127
-
128
- // --------------------------------------------------------------------------- /cliproxy-refresh
129
-
130
- async function handleRefresh(
131
- pi: ExtensionAPI,
132
- _args: string,
133
- ctx: ExtensionCommandContext,
134
- ): Promise<void> {
135
- const cfg = loadConfig();
136
- const resolvedKey = resolveConfigValue(cfg.proxy.apiKey);
137
- try {
138
- const discovery = await fetchDiscovery(cfg, resolvedKey);
139
- const rep = await applyAll(pi, cfg, discovery);
140
- clearUsageCache();
141
- ctx.ui.notify(
142
- `cliproxy: ${rep.registered.length} providers registered, ${rep.skipped.length} skipped (source=${discovery.source})`,
143
- "info",
144
- );
145
- } catch (err) {
146
- ctx.ui.notify(`refresh failed: ${(err as Error).message}`, "error");
147
- }
148
- }
149
-
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
- // --------------------------------------------------------------------------- /cliproxy-usage
172
-
173
- async function handleUsage(
174
- args: string,
175
- ctx: ExtensionCommandContext,
176
- ): Promise<void> {
177
- const force = /(^|\s)--refresh(\s|$)/.test(args);
178
- const cfg = loadConfig();
179
- const usageKey = resolveConfigValue(cfg.proxy.usageKey);
180
- let doc;
181
- try {
182
- doc = await fetchUsage(cfg, usageKey, { force });
183
- } catch (err) {
184
- ctx.ui.notify(`usage failed: ${(err as Error).message}`, "error");
185
- return;
186
- }
187
- await showOverlay(ctx, "cliproxy-usage", {
188
- render: (state) =>
189
- renderUsage(doc, {
190
- showDisabled: state["d"] === true,
191
- verbose: state["v"] === true,
192
- }).join("\n"),
193
- toggles: [
194
- { key: "d", hint: "d disabled" },
195
- { key: "v", hint: "v verbose" },
196
- ],
197
- });
198
- }
199
-
200
- // --------------------------------------------------------------------------- /cliproxy-doctor
201
-
202
- async function handleDoctor(
203
- _args: string,
204
- ctx: ExtensionCommandContext,
205
- ): Promise<void> {
206
- const cfg = loadConfig();
207
- const lines: string[] = [];
208
- lines.push(`endpoint: ${cfg.proxy.endpoint}`);
209
- lines.push(
210
- `apiKey resolves: ${resolveConfigValue(cfg.proxy.apiKey) ? "yes" : "NO (empty after resolution)"}`,
211
- );
212
- lines.push(
213
- `usageKey resolves: ${cfg.proxy.usageKey ? (resolveConfigValue(cfg.proxy.usageKey) ? "yes" : "NO") : "not configured"}`,
214
- );
215
- lines.push(`user-agent: ${PLUGIN_USER_AGENT}`);
216
-
217
- try {
218
- const discovery = await fetchDiscovery(
219
- cfg,
220
- resolveConfigValue(cfg.proxy.apiKey),
221
- );
222
- lines.push("");
223
- lines.push(`discovery source: ${discovery.source}`);
224
- lines.push(`upstream version: ${discovery.upstreamVersion ?? "(unknown)"}`);
225
- lines.push(`upstream total ids: ${discovery.upstreamTotal}`);
226
- lines.push(
227
- `built-in providers seen: ${discovery.builtinProviders.map((p) => `${p.name}=${p.models.length}`).join(", ") || "(none)"}`,
228
- );
229
- lines.push(`custom pool size: ${discovery.customPool.length}`);
230
- } catch (err) {
231
- lines.push("");
232
- lines.push(`discovery FAILED: ${(err as Error).message}`);
233
- }
234
-
235
- const conflicts = detectConflicts(cfg);
236
- if (conflicts.length > 0) {
237
- lines.push("");
238
- lines.push("conflicts:");
239
- for (const c of conflicts) lines.push(` [${c.kind}] ${c.detail}`);
240
- } else {
241
- lines.push("");
242
- lines.push("conflicts: none");
243
- }
244
-
245
- log.info("doctor:", lines.join(" | "));
246
- await showOverlay(ctx, "cliproxy-doctor", lines.join("\n"));
247
- }
@@ -20,7 +20,7 @@ import {
20
20
  import type { ProxyConfig } from "./config.ts";
21
21
  import { log } from "./log.ts";
22
22
 
23
- export const PLUGIN_USER_AGENT = "pi-cliproxyapi/0.1.0";
23
+ export const PLUGIN_USER_AGENT = "pi-cliproxyapi/0.3.0";
24
24
  const REQUEST_TIMEOUT_MS = 5_000;
25
25
 
26
26
  export interface DiscoveryModelEntry {
@@ -215,10 +215,7 @@ async function fetchRawModels(
215
215
  .filter((m) => m.id);
216
216
  }
217
217
 
218
- function classifyLocally(
219
- raw: RawUpstreamModel[],
220
- cfg: ProxyConfig,
221
- ): Discovery {
218
+ function classifyLocally(raw: RawUpstreamModel[], cfg: ProxyConfig): Discovery {
222
219
  const excludes = cfg.discoveryExcludes;
223
220
  const builtinByName = new Map<string, DiscoveryBuiltinProvider>();
224
221
  const customPool: DiscoveryCustomEntry[] = [];
package/src/log.ts CHANGED
@@ -1,19 +1,32 @@
1
1
  // Tiny logger that prefixes all messages with the extension tag.
2
2
  // Uses console.* directly — pi pipes stdout/stderr into its log sink.
3
+ //
4
+ // While an interactive overlay is open, console output corrupts the TUI (it
5
+ // prints over the box and forces a redraw below it — stacking headers). The
6
+ // overlay code wraps its lifetime in setLogQuiet(true) to mute us; user-facing
7
+ // results are surfaced inside the overlay instead.
3
8
 
4
9
  const TAG = "[pi-cliproxyapi]";
5
10
 
11
+ let quiet = false;
12
+
13
+ /** Mute/unmute all console output (used around interactive overlays). */
14
+ export function setLogQuiet(v: boolean): void {
15
+ quiet = v;
16
+ }
17
+
6
18
  export const log = {
7
19
  info(...args: unknown[]): void {
8
- console.log(TAG, ...args);
20
+ if (!quiet) console.log(TAG, ...args);
9
21
  },
10
22
  warn(...args: unknown[]): void {
11
- console.warn(TAG, ...args);
23
+ if (!quiet) console.warn(TAG, ...args);
12
24
  },
13
25
  error(...args: unknown[]): void {
14
- console.error(TAG, ...args);
26
+ if (!quiet) console.error(TAG, ...args);
15
27
  },
16
28
  debug(...args: unknown[]): void {
17
- if (process.env.PI_CLIPROXYAPI_DEBUG) console.log(TAG, "[debug]", ...args);
29
+ if (!quiet && process.env.PI_CLIPROXYAPI_DEBUG)
30
+ console.log(TAG, "[debug]", ...args);
18
31
  },
19
32
  };
@@ -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
+ }