pi-cliproxyapi 0.2.0 → 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,37 +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` | Three-panel pickerproviders, assigned models, available pool |
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-usage` | Per-account quota windows with progress bars (`d` = show disabled, `v` = verbose) |
26
- | `/cliproxy-doctor` | Connectivity, key resolution, discovery diagnostics |
27
28
 
28
- ### `/cliproxy` picker
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.
29
32
 
30
- The picker has three panels you cycle through with `Tab` / arrow keys:
33
+ **Models tab** three panels cycled with `Tab` / arrows:
31
34
 
32
35
  - **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.
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.
35
42
 
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.
43
+ **Diagnostics tab** connectivity, key resolution, and discovery shape.
37
44
 
38
45
  ## Prerequisites
39
46
 
40
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.
41
48
 
42
- 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.
43
50
 
44
51
  ## Install
45
52
 
@@ -85,7 +92,7 @@ The plugin tries `GET <endpoint-origin>/.well-known/pi` first (requires the side
85
92
  The **[pi-cliproxyapi-wellknown](https://github.com/abix5/pi-cliproxyapi-wellknown)** sidecar runs alongside CliProxyAPI and provides:
86
93
 
87
94
  - `/.well-known/pi` — model discovery with metadata from [models.dev](https://models.dev) (context windows, costs, reasoning flags)
88
- - `/api/usage` — per-account quota windows used by `/cliproxy-usage`
95
+ - `/api/usage` — per-account quota windows used by the hub Usage tab
89
96
 
90
97
  ```
91
98
  ┌──────────────┐ ┌───────────────────────────┐
@@ -143,16 +150,16 @@ Run `/cliproxy-setup` in Pi and enter:
143
150
  - **endpoint** — your public proxy URL ending with `/v1`
144
151
  - **apiKey** — CliProxyAPI bearer key
145
152
  - **providerPrefix** — short slug for custom provider names (e.g. `corp`, `myproxy`)
146
- - **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)
147
154
 
148
155
  The sidecar is **optional for basic usage** — without it the plugin falls back to raw `/v1/models` with local heuristics. What changes:
149
156
 
150
157
  | | With sidecar | Without sidecar |
151
158
  | --- | --- | --- |
152
159
  | 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) |
160
+ | Usage tab | Works — per-account quota bars | **Does not work** (no `/api/usage` endpoint) |
154
161
  | Classification | Server-side, accurate | Local heuristics by `owned_by` |
155
- | `/cliproxy`, `/cliproxy-doctor` | Work | Work |
162
+ | `/cliproxy` hub | Works | Works (Usage tab shows an error) |
156
163
 
157
164
  ## Layout
158
165
 
@@ -160,26 +167,30 @@ The sidecar is **optional for basic usage** — without it the plugin falls back
160
167
  index.ts ExtensionFactory entry point
161
168
  src/
162
169
  config.ts ~/.config/pi-cliproxyapi/config.json
163
- commands.ts 5 slash commands
170
+ commands.ts 2 slash commands (hub + setup)
164
171
  apply.ts pi.registerProvider calls
165
172
  fetch-models.ts well-known + /v1/models fallback
166
173
  fetch-usage.ts /api/usage client with TTL cache
167
174
  compat.ts baseUrl derivation, model classification
168
175
  conflicts.ts read-only ~/.pi/{models,auth}.json scan
169
176
  ui-frame.ts single source of truth for overlay frames
170
- ui-overlay.ts scrollable overlay shell with toggles
171
177
  ui-setup.ts setup wizard
172
178
  ui-usage.ts ANSI-coloured usage renderer
173
- ui-picker/ three-panel /cliproxy overlay
174
- index.ts public runPicker entry
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
175
188
  types.ts shared TS types
176
189
  catalog.ts build a model lookup from discovery
177
190
  providers.ts resolve the providers shown in the left panel
178
- mutate.ts attach / detach / claim helpers + pool grouping
191
+ mutate.ts attach / detach / claim + pool grouping + display order
179
192
  render-text.ts ANSI-aware pad / truncate
180
193
  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
194
  prompt-confirm.ts remove-group confirmation
184
195
  prompt-name.ts new-group name prompt
185
196
  log.ts tagged logger
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-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.2.0",
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/commands.ts CHANGED
@@ -1,9 +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-usage — fetch /api/usage and render in overlay
6
- // /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.
7
6
 
8
7
  import type {
9
8
  ExtensionAPI,
@@ -11,20 +10,16 @@ import type {
11
10
  } from "@earendil-works/pi-coding-agent";
12
11
 
13
12
  import { applyAll } from "./apply.ts";
14
- import { loadConfig, resolveConfigValue, saveConfig } from "./config.ts";
15
- import { detectConflicts } from "./conflicts.ts";
16
- import { fetchDiscovery, PLUGIN_USER_AGENT } from "./fetch-models.ts";
17
- import { clearUsageCache, fetchUsage } from "./fetch-usage.ts";
18
- import { log } from "./log.ts";
19
- import { showOverlay } from "./ui-overlay.ts";
20
- import { runPicker } from "./ui-picker/index.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";
21
17
  import { runSetup } from "./ui-setup.ts";
22
- import { renderUsage } from "./ui-usage.ts";
23
18
 
24
19
  export function registerCommands(pi: ExtensionAPI): void {
25
20
  pi.registerCommand("cliproxy", {
26
21
  description:
27
- "Pick which models to expose via the CliProxyAPI corporate proxy",
22
+ "Manage proxy models, usage, and diagnostics in one hub overlay",
28
23
  handler: handleCliproxy.bind(null, pi),
29
24
  });
30
25
 
@@ -33,22 +28,6 @@ export function registerCommands(pi: ExtensionAPI): void {
33
28
  "Set endpoint, API key, and (optional) usage key for the proxy",
34
29
  handler: handleSetup.bind(null, pi),
35
30
  });
36
-
37
- pi.registerCommand("cliproxy-refresh", {
38
- description:
39
- "Re-fetch upstream model list and re-apply provider registrations",
40
- handler: handleRefresh.bind(null, pi),
41
- });
42
-
43
- pi.registerCommand("cliproxy-usage", {
44
- description: "Show per-account quota windows from the upstream",
45
- handler: handleUsage,
46
- });
47
-
48
- pi.registerCommand("cliproxy-doctor", {
49
- description: "Check connectivity, key resolution, and discovery shape",
50
- handler: handleDoctor,
51
- });
52
31
  }
53
32
 
54
33
  // --------------------------------------------------------------------------- /cliproxy
@@ -61,7 +40,7 @@ async function handleCliproxy(
61
40
  const cfg = loadConfig();
62
41
  if (!cfg.proxy.endpoint || !resolveConfigValue(cfg.proxy.apiKey)) {
63
42
  ctx.ui.notify(
64
- "endpoint or API key not set \u2014 launching /cliproxy-setup first",
43
+ "endpoint or API key not set \u2014 launching setup first",
65
44
  "info",
66
45
  );
67
46
  const ok = await runSetup(ctx, true);
@@ -76,17 +55,7 @@ async function handleCliproxy(
76
55
  ctx.ui.notify(`discovery failed: ${(err as Error).message}`, "error");
77
56
  return;
78
57
  }
79
- const updated = await runPicker(ctx, current, discovery);
80
- if (!updated) {
81
- ctx.ui.notify("changes discarded", "info");
82
- return;
83
- }
84
- saveConfig(updated);
85
- const rep = await applyAll(pi, updated, discovery);
86
- ctx.ui.notify(
87
- `saved \u00b7 ${rep.registered.length} providers registered, ${rep.skipped.length} skipped`,
88
- "info",
89
- );
58
+ await runHub(pi, ctx, current, discovery);
90
59
  }
91
60
 
92
61
  // --------------------------------------------------------------------------- /cliproxy-setup
@@ -118,103 +87,3 @@ async function handleSetup(
118
87
  );
119
88
  }
120
89
  }
121
-
122
- // --------------------------------------------------------------------------- /cliproxy-refresh
123
-
124
- async function handleRefresh(
125
- pi: ExtensionAPI,
126
- _args: string,
127
- ctx: ExtensionCommandContext,
128
- ): Promise<void> {
129
- const cfg = loadConfig();
130
- const resolvedKey = resolveConfigValue(cfg.proxy.apiKey);
131
- try {
132
- const discovery = await fetchDiscovery(cfg, resolvedKey);
133
- const rep = await applyAll(pi, cfg, discovery);
134
- clearUsageCache();
135
- ctx.ui.notify(
136
- `cliproxy: ${rep.registered.length} providers registered, ${rep.skipped.length} skipped (source=${discovery.source})`,
137
- "info",
138
- );
139
- } catch (err) {
140
- ctx.ui.notify(`refresh failed: ${(err as Error).message}`, "error");
141
- }
142
- }
143
-
144
- // --------------------------------------------------------------------------- /cliproxy-usage
145
-
146
- async function handleUsage(
147
- args: string,
148
- ctx: ExtensionCommandContext,
149
- ): Promise<void> {
150
- const force = /(^|\s)--refresh(\s|$)/.test(args);
151
- const cfg = loadConfig();
152
- const usageKey = resolveConfigValue(cfg.proxy.usageKey);
153
- let doc;
154
- try {
155
- doc = await fetchUsage(cfg, usageKey, { force });
156
- } catch (err) {
157
- ctx.ui.notify(`usage failed: ${(err as Error).message}`, "error");
158
- return;
159
- }
160
- await showOverlay(ctx, "cliproxy-usage", {
161
- render: (state) =>
162
- renderUsage(doc, {
163
- showDisabled: state["d"] === true,
164
- verbose: state["v"] === true,
165
- }).join("\n"),
166
- toggles: [
167
- { key: "d", hint: "d disabled" },
168
- { key: "v", hint: "v verbose" },
169
- ],
170
- });
171
- }
172
-
173
- // --------------------------------------------------------------------------- /cliproxy-doctor
174
-
175
- async function handleDoctor(
176
- _args: string,
177
- ctx: ExtensionCommandContext,
178
- ): Promise<void> {
179
- const cfg = loadConfig();
180
- const lines: string[] = [];
181
- lines.push(`endpoint: ${cfg.proxy.endpoint}`);
182
- lines.push(
183
- `apiKey resolves: ${resolveConfigValue(cfg.proxy.apiKey) ? "yes" : "NO (empty after resolution)"}`,
184
- );
185
- lines.push(
186
- `usageKey resolves: ${cfg.proxy.usageKey ? (resolveConfigValue(cfg.proxy.usageKey) ? "yes" : "NO") : "not configured"}`,
187
- );
188
- lines.push(`user-agent: ${PLUGIN_USER_AGENT}`);
189
-
190
- try {
191
- const discovery = await fetchDiscovery(
192
- cfg,
193
- resolveConfigValue(cfg.proxy.apiKey),
194
- );
195
- lines.push("");
196
- lines.push(`discovery source: ${discovery.source}`);
197
- lines.push(`upstream version: ${discovery.upstreamVersion ?? "(unknown)"}`);
198
- lines.push(`upstream total ids: ${discovery.upstreamTotal}`);
199
- lines.push(
200
- `built-in providers seen: ${discovery.builtinProviders.map((p) => `${p.name}=${p.models.length}`).join(", ") || "(none)"}`,
201
- );
202
- lines.push(`custom pool size: ${discovery.customPool.length}`);
203
- } catch (err) {
204
- lines.push("");
205
- lines.push(`discovery FAILED: ${(err as Error).message}`);
206
- }
207
-
208
- const conflicts = detectConflicts(cfg);
209
- if (conflicts.length > 0) {
210
- lines.push("");
211
- lines.push("conflicts:");
212
- for (const c of conflicts) lines.push(` [${c.kind}] ${c.detail}`);
213
- } else {
214
- lines.push("");
215
- lines.push("conflicts: none");
216
- }
217
-
218
- log.info("doctor:", lines.join(" | "));
219
- await showOverlay(ctx, "cliproxy-doctor", lines.join("\n"));
220
- }
@@ -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,264 @@
1
+ // /cliproxy hub — one overlay with Models / Usage / Diagnostics tabs and a
2
+ // shared action bar. Replaces the old per-command overlays (-refresh, -usage,
3
+ // -doctor) with global keys inside a single surface.
4
+ //
5
+ // global keys: [ ] / 1 2 3 switch tab \u00b7 r refresh \u00b7 e setup \u00b7 s save \u00b7 q close
6
+ // per-view keys: see each view's footerHint()
7
+
8
+ import type {
9
+ ExtensionAPI,
10
+ ExtensionCommandContext,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import {
13
+ type Component,
14
+ getKeybindings,
15
+ matchesKey,
16
+ } from "@earendil-works/pi-tui";
17
+
18
+ import { applyAll } from "../apply.ts";
19
+ import type { ProxyConfig } from "../config.ts";
20
+ import { loadConfig, resolveConfigValue, saveConfig } from "../config.ts";
21
+ import type { Discovery } from "../fetch-models.ts";
22
+ import { fetchDiscovery } from "../fetch-models.ts";
23
+ import { clearUsageCache } from "../fetch-usage.ts";
24
+ import { frame } from "../ui-frame.ts";
25
+ import type { OverlayTui, Theme } from "../ui-picker/types.ts";
26
+ import { runSetup } from "../ui-setup.ts";
27
+ import { ruleLine, statusHeader, tabBar } from "./shell.ts";
28
+ import type { HubView } from "./types.ts";
29
+ import { buildDiagnosticsView } from "./view-diagnostics.ts";
30
+ import { buildModelsView, type ModelsView } from "./view-models.ts";
31
+ import { buildUsageView, type UsageView } from "./view-usage.ts";
32
+
33
+ export interface HubDeps {
34
+ pi: ExtensionAPI;
35
+ ctx: ExtensionCommandContext;
36
+ tui: OverlayTui;
37
+ theme: Theme;
38
+ cfg: ProxyConfig; // mutable draft owned by the hub
39
+ discovery: Discovery;
40
+ done: () => void;
41
+ }
42
+
43
+ export function buildHub(
44
+ deps: HubDeps,
45
+ ): Component & { handleInput(data: string): void } {
46
+ const { pi, ctx, tui, theme, cfg, done } = deps;
47
+ let discovery = deps.discovery;
48
+ let dirty = false; // unsaved config changes
49
+ let flash = ""; // transient status message (save/refresh/setup feedback)
50
+
51
+ const models: ModelsView = buildModelsView({
52
+ tui,
53
+ theme,
54
+ ctx,
55
+ cfg,
56
+ getDiscovery: () => discovery,
57
+ onChange: () => {
58
+ dirty = true;
59
+ flash = "";
60
+ },
61
+ });
62
+ const usage: UsageView = buildUsageView({ tui, theme, cfg });
63
+ const diagnostics: HubView = buildDiagnosticsView({
64
+ theme,
65
+ cfg,
66
+ getDiscovery: () => discovery,
67
+ });
68
+ const views: HubView[] = [models, usage, diagnostics];
69
+ let activeIdx = 0;
70
+ const active = (): HubView => views[activeIdx]!;
71
+
72
+ const switchTab = (idx: number): void => {
73
+ if (idx < 0 || idx >= views.length || idx === activeIdx) return;
74
+ activeIdx = idx;
75
+ active().onActivate?.();
76
+ tui.requestRender();
77
+ };
78
+
79
+ const save = (): void => {
80
+ flash = theme.fg("dim", "saving\u2026");
81
+ saveConfig(cfg);
82
+ tui.requestRender();
83
+ void applyAll(pi, cfg, discovery)
84
+ .then((rep) => {
85
+ dirty = false;
86
+ flash = theme.fg(
87
+ "success",
88
+ `\u2713 settings saved \u00b7 ${rep.registered.length} registered, ${rep.skipped.length} skipped`,
89
+ );
90
+ tui.requestRender();
91
+ })
92
+ .catch((e: unknown) => {
93
+ flash = theme.fg("error", `save failed: ${(e as Error).message}`);
94
+ tui.requestRender();
95
+ });
96
+ };
97
+
98
+ const refresh = async (): Promise<void> => {
99
+ flash = theme.fg("dim", "refreshing\u2026");
100
+ tui.requestRender();
101
+ try {
102
+ const key = resolveConfigValue(cfg.proxy.apiKey);
103
+ discovery = await fetchDiscovery(cfg, key);
104
+ clearUsageCache();
105
+ models.rebuild();
106
+ usage.reload();
107
+ const rep = await applyAll(pi, cfg, discovery);
108
+ flash = theme.fg(
109
+ "success",
110
+ `\u2713 refreshed \u00b7 ${rep.registered.length} providers (${discovery.source})`,
111
+ );
112
+ } catch (e) {
113
+ flash = theme.fg("error", `refresh failed: ${(e as Error).message}`);
114
+ }
115
+ tui.requestRender();
116
+ };
117
+
118
+ const setup = async (): Promise<void> => {
119
+ const ok = await runSetup(ctx, true);
120
+ if (!ok) {
121
+ tui.requestRender();
122
+ return;
123
+ }
124
+ // Pull fresh credentials but keep the user's in-hub model edits.
125
+ const reloaded = loadConfig();
126
+ cfg.proxy = { ...cfg.proxy, ...reloaded.proxy };
127
+ await refresh();
128
+ };
129
+
130
+ // ----- chrome ------------------------------------------------------------
131
+ const statusParts = (): string[] => {
132
+ const ep = cfg.proxy.endpoint || "(unset)";
133
+ const epShort = ep.length > 40 ? `\u2026${ep.slice(-39)}` : ep;
134
+ const keyOk = Boolean(resolveConfigValue(cfg.proxy.apiKey));
135
+ let provCount = 0;
136
+ let modelCount = 0;
137
+ for (const p of Object.values(cfg.builtinProviders)) {
138
+ if (p.enabled && p.models.length > 0) provCount++;
139
+ modelCount += p.models.length;
140
+ }
141
+ for (const p of Object.values(cfg.customProviders)) {
142
+ if (p.models.length > 0) provCount++;
143
+ modelCount += p.models.length;
144
+ }
145
+ const state = flash
146
+ ? flash
147
+ : dirty
148
+ ? theme.fg("warning", "\u25cf unsaved \u2014 press s to save")
149
+ : theme.fg("dim", "\u2713 saved");
150
+ return [
151
+ `${theme.fg("dim", "endpoint")} ${epShort}`,
152
+ `${theme.fg("dim", "key")} ${keyOk ? theme.fg("success", "\u2713") : theme.fg("error", "\u2717")}`,
153
+ `${theme.fg("dim", "providers")} ${provCount}`,
154
+ `${theme.fg("dim", "models")} ${modelCount}`,
155
+ state,
156
+ ];
157
+ };
158
+
159
+ const render = (width: number): string[] => {
160
+ const totalRows = tui.rows ?? 40;
161
+ // frame() adds a top + bottom border, so the lines we hand it must total
162
+ // `frameTotal - 2`. Keep frameTotal within the overlay's 94% budget
163
+ // (proven range 16\u201338) so the box never overflows and gets clipped
164
+ // \u2014 clipping is what made the top unreachable and shifted the window.
165
+ const frameTotal = Math.max(16, Math.min(totalRows - 6, 38));
166
+ const bodyRows = frameTotal - 2;
167
+ const inner = Math.max(72, width - 2);
168
+ const viewBodyH = Math.max(6, bodyRows - 3); // minus status + tab + rule
169
+
170
+ const lines: string[] = [];
171
+ lines.push(statusHeader(theme, statusParts(), inner));
172
+ lines.push(
173
+ tabBar(
174
+ theme,
175
+ views.map((v) => ({ id: v.id, label: v.label })),
176
+ activeIdx,
177
+ inner,
178
+ ),
179
+ );
180
+ lines.push(ruleLine(theme, inner));
181
+ lines.push(...active().render(inner, viewBodyH));
182
+
183
+ return frame(theme, {
184
+ width,
185
+ title: " /cliproxy ",
186
+ lines,
187
+ footer: {
188
+ hint: active().footerHint(),
189
+ badge:
190
+ " [ ] tab \u00b7 r refresh \u00b7 e setup \u00b7 s save \u00b7 q close ",
191
+ },
192
+ });
193
+ };
194
+
195
+ // ----- input -------------------------------------------------------------
196
+ const globalInput = (data: string): boolean | Promise<boolean> => {
197
+ const kb = getKeybindings();
198
+ if (
199
+ kb.matches(data, "tui.select.cancel") ||
200
+ matchesKey(data, "q") ||
201
+ matchesKey(data, "escape")
202
+ ) {
203
+ done();
204
+ return true;
205
+ }
206
+ if (matchesKey(data, "]") || matchesKey(data, "tab")) {
207
+ switchTab((activeIdx + 1) % views.length);
208
+ return true;
209
+ }
210
+ if (matchesKey(data, "[") || matchesKey(data, "shift+tab")) {
211
+ switchTab((activeIdx - 1 + views.length) % views.length);
212
+ return true;
213
+ }
214
+ if (matchesKey(data, "1")) {
215
+ switchTab(0);
216
+ return true;
217
+ }
218
+ if (matchesKey(data, "2")) {
219
+ switchTab(1);
220
+ return true;
221
+ }
222
+ if (matchesKey(data, "3")) {
223
+ switchTab(2);
224
+ return true;
225
+ }
226
+ if (matchesKey(data, "r")) return refresh().then(() => true);
227
+ if (matchesKey(data, "e")) return setup().then(() => true);
228
+ if (matchesKey(data, "s")) {
229
+ save();
230
+ return true;
231
+ }
232
+ return false;
233
+ };
234
+
235
+ return {
236
+ render,
237
+ invalidate(): void {
238
+ /* stateless */
239
+ },
240
+ handleInput(data: string): void {
241
+ const handled = active().handleInput(data);
242
+ if (handled instanceof Promise) {
243
+ flash = "";
244
+ void handled.then((h) => {
245
+ if (h) tui.requestRender();
246
+ });
247
+ return;
248
+ }
249
+ if (handled) {
250
+ flash = ""; // editing/navigating clears stale save feedback
251
+ tui.requestRender();
252
+ return;
253
+ }
254
+ const g = globalInput(data);
255
+ if (g instanceof Promise) {
256
+ void g.then((h) => {
257
+ if (h) tui.requestRender();
258
+ });
259
+ } else if (g) {
260
+ tui.requestRender();
261
+ }
262
+ },
263
+ };
264
+ }