pi-hide-providers 0.1.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 ADDED
@@ -0,0 +1,165 @@
1
+ <div align="center">
2
+
3
+ # 🔇 pi-hide-providers
4
+
5
+ **Hide providers and models from the selector in [pi](https://github.com/earendil-works/pi-coding-agent)**
6
+
7
+ _Filter the model picker so you only see the models you care about._
8
+
9
+ [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi-coding-agent)
10
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
11
+
12
+ <img src="assets/demo.jpg" alt="pi-hide-providers interactive model selector" width="800">
13
+
14
+ </div>
15
+
16
+ ---
17
+
18
+ ## The Problem
19
+
20
+ Pi's model selector shows **every** available model from every configured provider. If you have Ollama running with 20 local models, or an OpenRouter account with hundreds of options, the model list becomes noisy and slow to navigate. There's no built-in way to say *"I never want to see these providers/models in the selector."*
21
+
22
+ Pi has `enabledModels` in `settings.json` as an allowlist, but maintaining it manually is tedious — you have to list every model you *do* want, and clobber `settings.json` with hundreds of entries. What you really want is a **blocklist**: *"hide everything from these providers, except the ones I explicitly use."*
23
+
24
+ ## The Solution
25
+
26
+ `pi-hide-providers` gives you a blocklist that **completely removes** models from all lists — not an allowlist, not a scoped subset:
27
+
28
+ - Define hide rules in a config file (`~/.pi/agent/hide-providers.json` or `.pi/hide-providers.json`)
29
+ - On session start, the extension monkey-patches `modelRegistry.getAvailable()`, `getAll()`, and `find()` to filter out hidden models
30
+ - The `/model` selector, `Ctrl+P` cycling, `--list-models`, and session restoration all see only visible models
31
+ - `/hide-models reset` unpatches the registry — all models return immediately
32
+ - Changes via `/hide-models add` and `/hide-models remove` take effect immediately (no reload needed)
33
+ - Interactive `/hide-models` command — no-args opens the TUI selector; subcommands for adding, removing, and inspecting rules
34
+
35
+ No `settings.json` is modified. No 250+ entry explosion. No allowlist semantics.
36
+
37
+ ## Usage
38
+
39
+ ### Interactive Commands
40
+
41
+ | Command | What it does |
42
+ |---------|-------------|
43
+ | `/hide-models` | Open interactive TUI to select providers/models to hide |
44
+ | `/hide-models add ollama` | Hide the entire `ollama` provider |
45
+ | `/hide-models add openrouter/cheap-model` | Hide a specific model from `openrouter` |
46
+ | `/hide-models add openrouter/*` | Hide the entire `openrouter` provider (explicit) |
47
+ | `/hide-models remove ollama` | Remove the hide rule for `ollama` |
48
+ | `/hide-models status` | Show current rules, patch status, and hidden model count |
49
+ | `/hide-models apply` | Show current hide state (changes are already active) |
50
+ | `/hide-models reset` | Unpatch registry — all models return immediately |
51
+ | `/hide-models help` | Show usage reference |
52
+
53
+ ### Config File
54
+
55
+ Create `~/.pi/agent/hide-providers.json` (global) or `.pi/hide-providers.json` (project-local):
56
+
57
+ ```json
58
+ {
59
+ "hide": [
60
+ { "provider": "ollama" },
61
+ { "provider": "openrouter", "model": "cheap-model" },
62
+ { "provider": "github-copilot", "model": "gpt-3.5-turbo" }
63
+ ]
64
+ }
65
+ ```
66
+
67
+ **Rule formats:**
68
+
69
+ | Rule | Effect |
70
+ |------|--------|
71
+ | `{ "provider": "ollama" }` | Hide all models from the `ollama` provider |
72
+ | `{ "provider": "ollama", "model": "*" }` | Same — explicit wildcard |
73
+ | `{ "provider": "openrouter", "model": "cheap-model" }` | Hide only `openrouter/cheap-model` |
74
+
75
+ Project config (`.pi/hide-providers.json`) takes priority over global config (`~/.pi/agent/hide-providers.json`).
76
+
77
+ ## Installation
78
+
79
+ ```bash
80
+ pi install https://github.com/monotykamary/pi-hide-providers
81
+ ```
82
+
83
+ Or in `~/.pi/agent/settings.json`:
84
+
85
+ ```json
86
+ {
87
+ "packages": [
88
+ "https://github.com/monotykamary/pi-hide-providers"
89
+ ]
90
+ }
91
+ ```
92
+
93
+ Then `/reload` or restart pi.
94
+
95
+ For quick one-off tests:
96
+
97
+ ```bash
98
+ pi -e ./hide-providers.ts
99
+ ```
100
+
101
+ ## How It Works
102
+
103
+ ```
104
+ Session starts
105
+ → Extension reads hide-providers.json
106
+ → Monkey-patches modelRegistry:
107
+ getAvailable() → original result filtered by isHidden()
108
+ getAll() → original result filtered by isHidden()
109
+ find(p, m) → returns undefined if isHidden(p, m)
110
+ → All downstream consumers see only visible models:
111
+ /model selector, Ctrl+P, --list-models, session restoration
112
+
113
+ /hide-models add or /hide-models remove:
114
+ → Config updated on disk
115
+ → currentRules updated in memory
116
+ → Patched methods read latest rules via closure
117
+ → Changes take effect immediately (no reload)
118
+
119
+ /hide-models reset:
120
+ → Unpatches registry (restores original methods)
121
+ → All models return immediately
122
+ ```
123
+
124
+ The SDK doesn't provide a mechanism to remove models from the registry — `registerProvider({ models: [] })` is treated as "no models to register" (override-only), not "remove all models." Monkey-patching the accessor methods is the only way to completely remove models from all lists without touching `settings.json`.
125
+
126
+ The patches survive `modelRegistry.refresh()` because they wrap the original methods. On reload, the extension detects the registry is already patched and just updates the rules source.
127
+
128
+ ## Comparison with Alternatives
129
+
130
+ | Approach | Pros | Cons |
131
+ |----------|------|------|
132
+ | **pi-hide-providers** (this) | Blocklist — completely removes models from all lists; no `settings.json` writes; changes take effect immediately; survives `refresh()` | Monkey-patches `modelRegistry` methods (not an official SDK mechanism) |
133
+ | `enabledModels` in `settings.json` (manual) | Built-in, no extension needed | Allowlist — must list every model you want individually; no blocklist support; clobbers settings with hundreds of entries |
134
+ | `--models` CLI flag | Per-session scoping | Must pass every time; no persistence |
135
+ | `pi.unregisterProvider()` | Restores built-in models after override | Only works for providers registered via `pi.registerProvider()`; can't hide entire providers (empty models array is a no-op) |
136
+ | `pi-model-router` scope shim | Dynamic scoping with routing | Heavyweight — full routing system just to filter the model list |
137
+
138
+ ## Development
139
+
140
+ ```bash
141
+ npm install
142
+ npm test # Vitest unit tests
143
+ npm run typecheck # TypeScript validation
144
+ npm run lint:dead # Dead code detection (knip)
145
+ ```
146
+
147
+ ### Structure
148
+
149
+ ```
150
+ .
151
+ ├── hide-providers.ts # Main extension
152
+ ├── src/
153
+ │ └── index.ts # Constants, types, and utilities
154
+ ├── __tests__/
155
+ │ └── unit/
156
+ │ └── hide-providers.test.ts
157
+ ├── package.json
158
+ ├── tsconfig.json
159
+ ├── vitest.config.ts
160
+ └── knip.json
161
+ ```
162
+
163
+ ## License
164
+
165
+ MIT
@@ -0,0 +1,497 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ HIDE_COMMAND_DESCRIPTION,
4
+ CONFIG_FILENAME,
5
+ type HideRule,
6
+ type HideProvidersConfig,
7
+ isHidden,
8
+ parseRule,
9
+ formatRule,
10
+ deduplicateRules,
11
+ } from "./src/index.js";
12
+ import { HideProviderSelectorComponent, type HideProviderSelectorResult } from "./src/provider-selector.js";
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { homedir } from "node:os";
16
+
17
+ /**
18
+ * pi-hide-providers — hide providers and models from pi's model selector.
19
+ *
20
+ * Strategy: monkey-patches modelRegistry accessor methods (getAvailable, getAll, find)
21
+ * to filter out models matched by hide rules.
22
+ *
23
+ * This is the only mechanism that completely removes models from ALL lists:
24
+ * the /model selector, Ctrl+P cycling, --list-models CLI, and session restoration.
25
+ * It survives modelRegistry.refresh() because our patches wrap the originals.
26
+ * No settings.json is touched — no 250+ entry explosion, no allowlist semantics.
27
+ */
28
+
29
+ // Config paths
30
+ const globalConfigDir = join(homedir(), ".pi", "agent");
31
+ const globalConfigPath = join(globalConfigDir, CONFIG_FILENAME);
32
+
33
+ function getProjectConfigPath(cwd: string): string {
34
+ return join(cwd, ".pi", CONFIG_FILENAME);
35
+ }
36
+
37
+ // Read config from disk (project overrides global)
38
+ function readConfig(cwd: string): HideProvidersConfig {
39
+ const projectPath = getProjectConfigPath(cwd);
40
+ const path = existsSync(projectPath) ? projectPath : globalConfigPath;
41
+
42
+ if (!existsSync(path)) {
43
+ return { hide: [] };
44
+ }
45
+
46
+ try {
47
+ const raw = readFileSync(path, "utf8");
48
+ const parsed = JSON.parse(raw) as HideProvidersConfig;
49
+ if (!Array.isArray(parsed.hide)) {
50
+ return { hide: [] };
51
+ }
52
+ return { hide: deduplicateRules(parsed.hide) };
53
+ } catch {
54
+ return { hide: [] };
55
+ }
56
+ }
57
+
58
+ // Write config to disk
59
+ function writeConfig(cwd: string, config: HideProvidersConfig): string {
60
+ const projectPath = getProjectConfigPath(cwd);
61
+ const path = existsSync(getProjectConfigPath(cwd)) ? projectPath : globalConfigPath;
62
+ const dir = path === projectPath ? join(cwd, ".pi") : globalConfigDir;
63
+
64
+ if (!existsSync(dir)) {
65
+ mkdirSync(dir, { recursive: true });
66
+ }
67
+
68
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf8");
69
+ return path;
70
+ }
71
+
72
+ // Monkey-patching helpers
73
+
74
+ const PATCH_KEY = "__hide_providers_patched";
75
+
76
+ interface PatchedRegistry {
77
+ [PATCH_KEY]: boolean;
78
+ getAvailable(): unknown[];
79
+ getAll(): unknown[];
80
+ find(provider: string, modelId: string): unknown | undefined;
81
+ __hide_providers_get_rules: () => HideRule[];
82
+ __hide_providers_orig_getAvailable: () => unknown[];
83
+ __hide_providers_orig_getAll: () => unknown[];
84
+ __hide_providers_orig_find: (provider: string, modelId: string) => unknown | undefined;
85
+ }
86
+
87
+ // Patch a model registry to filter out hidden models.
88
+ // If already patched (e.g. after reload), just updates the rules source.
89
+ function patchRegistry(
90
+ registry: PatchedRegistry,
91
+ getRules: () => HideRule[],
92
+ ): void {
93
+ if (registry[PATCH_KEY]) {
94
+ registry.__hide_providers_get_rules = getRules;
95
+ return;
96
+ }
97
+
98
+ registry[PATCH_KEY] = true;
99
+ registry.__hide_providers_get_rules = getRules;
100
+
101
+ // Save originals
102
+ registry.__hide_providers_orig_getAvailable = registry.getAvailable.bind(registry);
103
+ registry.__hide_providers_orig_getAll = registry.getAll.bind(registry);
104
+ registry.__hide_providers_orig_find = registry.find.bind(registry);
105
+
106
+ // Patch getAvailable — used by model selector, Ctrl+P cycle, resolveModelScope
107
+ registry.getAvailable = function (this: PatchedRegistry) {
108
+ const rules = this.__hide_providers_get_rules();
109
+ const all = this.__hide_providers_orig_getAvailable();
110
+ return all.filter(
111
+ (m: any) => !isHidden(rules, m.provider, m.id),
112
+ );
113
+ };
114
+
115
+ // Patch getAll — used by --list-models and CLI model resolution
116
+ registry.getAll = function (this: PatchedRegistry) {
117
+ const rules = this.__hide_providers_get_rules();
118
+ const all = this.__hide_providers_orig_getAll();
119
+ return all.filter(
120
+ (m: any) => !isHidden(rules, m.provider, m.id),
121
+ );
122
+ };
123
+
124
+ // Patch find — used by session restoration. Hides hidden models from being restored.
125
+ registry.find = function (
126
+ this: PatchedRegistry,
127
+ provider: string,
128
+ modelId: string,
129
+ ) {
130
+ const rules = this.__hide_providers_get_rules();
131
+ if (isHidden(rules, provider, modelId)) return undefined;
132
+ return this.__hide_providers_orig_find(provider, modelId);
133
+ };
134
+ }
135
+
136
+ // Restore original methods on a patched registry.
137
+ function unpatchRegistry(registry: PatchedRegistry): void {
138
+ if (!registry[PATCH_KEY]) return;
139
+
140
+ registry.getAvailable = registry.__hide_providers_orig_getAvailable;
141
+ registry.getAll = registry.__hide_providers_orig_getAll;
142
+ registry.find = registry.__hide_providers_orig_find;
143
+
144
+ delete (registry as any)[PATCH_KEY];
145
+ delete (registry as any).__hide_providers_get_rules;
146
+ delete (registry as any).__hide_providers_orig_getAvailable;
147
+ delete (registry as any).__hide_providers_orig_getAll;
148
+ delete (registry as any).__hide_providers_orig_find;
149
+ }
150
+
151
+ // Extension
152
+
153
+ export default function (pi: ExtensionAPI) {
154
+ let currentRules: HideRule[] = [];
155
+
156
+ pi.on("session_start", async (_event, ctx) => {
157
+ const config = readConfig(ctx.cwd);
158
+ currentRules = config.hide;
159
+
160
+ if (currentRules.length > 0) {
161
+ patchRegistry(ctx.modelRegistry as unknown as PatchedRegistry, () => currentRules);
162
+
163
+ if (ctx.hasUI) {
164
+ ctx.ui.notify(
165
+ `pi-hide-providers: ${currentRules.length} rule(s) active — getAvailable/getAll/find patched to filter hidden models`,
166
+ "info",
167
+ );
168
+ }
169
+ }
170
+ });
171
+
172
+ // Safety net: block selection of hidden models if they somehow show up
173
+ pi.on("model_select", async (event, ctx) => {
174
+ if (isHidden(currentRules, event.model.provider, event.model.id)) {
175
+ ctx.ui.notify(
176
+ `Blocked: ${event.model.provider}/${event.model.id} is hidden by pi-hide-providers`,
177
+ "warning",
178
+ );
179
+ }
180
+ });
181
+
182
+ // /hide-models command — interactive management
183
+ pi.registerCommand("hide-models", {
184
+ description: HIDE_COMMAND_DESCRIPTION,
185
+ getArgumentCompletions(prefix: string) {
186
+ const subcommands = ["add", "remove", "status", "list", "apply", "reset"];
187
+ const matches = subcommands.filter((s) => s.startsWith(prefix));
188
+ return matches.length > 0 ? matches.map((s) => ({ value: s, label: s })) : null;
189
+ },
190
+ handler: async (args, ctx) => {
191
+ await handleHideCommand(ctx, args.trim(), currentRules, (rules) => {
192
+ currentRules = rules;
193
+ });
194
+ },
195
+ });
196
+ }
197
+
198
+ async function handleHideCommand(
199
+ ctx: ExtensionCommandContext,
200
+ args: string,
201
+ currentRules: HideRule[],
202
+ setRules: (rules: HideRule[]) => void,
203
+ ): Promise<void> {
204
+ const parts = args.split(/\s+/);
205
+ const subcommand = parts[0]?.toLowerCase() ?? "";
206
+ const rest = parts.slice(1).join(" ");
207
+
208
+ // /hide-models — open interactive TUI selector (default action)
209
+ if (!subcommand) {
210
+ await showHideSelector(ctx, currentRules, setRules);
211
+ return;
212
+ }
213
+
214
+ // /hide-models list — show rules
215
+ if (subcommand === "list") {
216
+ showStatus(ctx, currentRules);
217
+ return;
218
+ }
219
+
220
+ // /hide-models add <rule> — add a hide rule
221
+ if (subcommand === "add") {
222
+ if (!rest) {
223
+ ctx.ui.notify(
224
+ "Usage: /hide-models add <provider> | <provider/model-id> | <provider/*>",
225
+ "warning",
226
+ );
227
+ return;
228
+ }
229
+
230
+ const rule = parseRule(rest);
231
+ if (!rule) {
232
+ ctx.ui.notify(
233
+ `Invalid rule: "${rest}". Use "provider" or "provider/model-id".`,
234
+ "error",
235
+ );
236
+ return;
237
+ }
238
+
239
+ const updated = deduplicateRules([...currentRules, rule]);
240
+ const configPath = writeConfig(ctx.cwd, { hide: updated });
241
+ setRules(updated);
242
+ ctx.ui.notify(
243
+ `Added: ${formatRule(rule)} (config: ${configPath}). Changes take effect immediately.`,
244
+ "info",
245
+ );
246
+ return;
247
+ }
248
+
249
+ // /hide-models remove <rule> — remove a hide rule
250
+ if (subcommand === "remove") {
251
+ if (!rest) {
252
+ ctx.ui.notify(
253
+ "Usage: /hide-models remove <provider> | <provider/model-id> | <provider/*>",
254
+ "warning",
255
+ );
256
+ return;
257
+ }
258
+
259
+ const rule = parseRule(rest);
260
+ if (!rule) {
261
+ ctx.ui.notify(
262
+ `Invalid rule: "${rest}". Use "provider" or "provider/model-id".`,
263
+ "error",
264
+ );
265
+ return;
266
+ }
267
+
268
+ const key = formatRule(rule);
269
+ const before = currentRules.length;
270
+ const updated = currentRules.filter((r) => formatRule(r) !== key);
271
+
272
+ if (updated.length === before) {
273
+ ctx.ui.notify(`Rule not found: ${key}`, "warning");
274
+ return;
275
+ }
276
+
277
+ writeConfig(ctx.cwd, { hide: updated });
278
+ setRules(updated);
279
+ ctx.ui.notify(
280
+ `Removed: ${key}. Changes take effect immediately.`,
281
+ "info",
282
+ );
283
+ return;
284
+ }
285
+
286
+ // /hide-models status — show current status
287
+ if (subcommand === "status") {
288
+ showStatus(ctx, currentRules);
289
+ return;
290
+ }
291
+
292
+ // /hide-models apply — notification (changes already active via patched methods)
293
+ if (subcommand === "apply") {
294
+ if (currentRules.length === 0) {
295
+ ctx.ui.notify("No hide rules configured. Use /hide-models add to create rules.", "warning");
296
+ return;
297
+ }
298
+
299
+ const total = countTotalFromUnpatched(ctx);
300
+ const hidden = hiddenFromUnpatched(ctx, currentRules);
301
+
302
+ ctx.ui.notify(
303
+ `Applied: ${currentRules.length} rule(s) active — ${total - hidden} visible, ${hidden} hidden (registry methods are patched)`,
304
+ "info",
305
+ );
306
+ return;
307
+ }
308
+
309
+ // /hide-models reset — unpatch registry (takes effect immediately)
310
+ if (subcommand === "reset") {
311
+ const registry = ctx.modelRegistry as unknown as PatchedRegistry;
312
+ if (!registry[PATCH_KEY]) {
313
+ ctx.ui.notify("Registry is not patched. Nothing to reset.", "info");
314
+ return;
315
+ }
316
+
317
+ unpatchRegistry(registry);
318
+ ctx.ui.notify(
319
+ "Reset: registry unpatched — all models restored immediately.",
320
+ "info",
321
+ );
322
+ return;
323
+ }
324
+
325
+ // /hide-models help
326
+ if (subcommand === "help") {
327
+ ctx.ui.notify(
328
+ [
329
+ "pi-hide-providers commands:",
330
+ " /hide-models Open interactive TUI to select providers/models to hide",
331
+ " /hide-models status Show current rules and status",
332
+ " /hide-models list Same as /hide-models status",
333
+ " /hide-models add <rule> Add a hide rule (e.g. ollama, openrouter/cheap-model)",
334
+ " /hide-models remove <rule> Remove a hide rule",
335
+ " /hide-models apply Show current hide state",
336
+ " /hide-models reset Unpatch registry — restore all models",
337
+ " /hide-models help This message",
338
+ "",
339
+ "Rule formats:",
340
+ ' "provider" Hide entire provider',
341
+ ' "provider/*" Hide entire provider (explicit)',
342
+ ' "provider/model-id" Hide specific model',
343
+ "",
344
+ "Mechanism: monkey-patches modelRegistry.getAvailable(),",
345
+ " getAll(), and find() to filter out hidden models.",
346
+ " Takes effect immediately. No settings.json modifications.",
347
+ " Survives refresh().",
348
+ ].join("\n"),
349
+ "info",
350
+ );
351
+ return;
352
+ }
353
+
354
+ ctx.ui.notify(
355
+ `Unknown subcommand: "${subcommand}". Use /hide-models help for usage.`,
356
+ "warning",
357
+ );
358
+ }
359
+
360
+ // Open the interactive TUI selector for hiding providers/models.
361
+ async function showHideSelector(
362
+ ctx: ExtensionCommandContext,
363
+ currentRules: HideRule[],
364
+ setRules: (rules: HideRule[]) => void,
365
+ ): Promise<void> {
366
+ // Get all models from the unpatched registry (so we see everything)
367
+ const registry = ctx.modelRegistry as unknown as PatchedRegistry;
368
+ const allModels = registry.__hide_providers_orig_getAll?.() ?? ctx.modelRegistry.getAll();
369
+
370
+ const models = (allModels as any[]).map((m: any) => ({
371
+ provider: m.provider as string,
372
+ id: m.id as string,
373
+ name: (m.name ?? m.id) as string,
374
+ }));
375
+
376
+ const result = await ctx.ui.custom<HideProviderSelectorResult>(
377
+ (tui, theme, _kb, done) => {
378
+ const selector = new HideProviderSelectorComponent(
379
+ theme,
380
+ models,
381
+ currentRules,
382
+ (result) => done(result),
383
+ );
384
+
385
+ return {
386
+ render(width: number) {
387
+ return selector.render(width);
388
+ },
389
+ invalidate() {
390
+ selector.invalidate();
391
+ },
392
+ handleInput(data: string) {
393
+ selector.handleInput(data);
394
+ tui.requestRender();
395
+ },
396
+ };
397
+ },
398
+ );
399
+
400
+ if (!result || result.cancelled) {
401
+ ctx.ui.notify("Hide selector cancelled.", "info");
402
+ return;
403
+ }
404
+
405
+ // Apply the new rules
406
+ const newRules = result.rules;
407
+ const configPath = writeConfig(ctx.cwd, { hide: newRules });
408
+ setRules(newRules);
409
+
410
+ if (newRules.length === 0) {
411
+ // No rules left — unpatch the registry
412
+ unpatchRegistry(registry);
413
+ ctx.ui.notify("All models visible. Registry unpatched.", "info");
414
+ } else {
415
+ // Ensure the registry is patched
416
+ patchRegistry(registry, () => newRules);
417
+ ctx.ui.notify(
418
+ `Hide rules updated: ${newRules.length} rule(s) active (config: ${configPath})`,
419
+ "info",
420
+ );
421
+ }
422
+ }
423
+
424
+ // Count total models using the original (unpatched) getAll.
425
+ function countTotalFromUnpatched(ctx: ExtensionCommandContext): number {
426
+ const registry = ctx.modelRegistry as unknown as PatchedRegistry;
427
+ const all = registry.__hide_providers_orig_getAll?.();
428
+ if (all) return all.length;
429
+ try {
430
+ return (ctx.modelRegistry.getAll() as any[]).length;
431
+ } catch {
432
+ return 0;
433
+ }
434
+ }
435
+
436
+ // Count hidden models using the original (unpatched) getAll.
437
+ function hiddenFromUnpatched(
438
+ ctx: ExtensionCommandContext,
439
+ rules: ReadonlyArray<HideRule>,
440
+ ): number {
441
+ const registry = ctx.modelRegistry as unknown as PatchedRegistry;
442
+ const all = registry.__hide_providers_orig_getAll?.();
443
+ if (all) {
444
+ return (all as any[]).filter((m: any) => isHidden(rules, m.provider, m.id)).length;
445
+ }
446
+ try {
447
+ return (ctx.modelRegistry.getAll() as any[]).filter(
448
+ (m: any) => isHidden(rules, m.provider, m.id),
449
+ ).length;
450
+ } catch {
451
+ return 0;
452
+ }
453
+ }
454
+
455
+ function showStatus(
456
+ ctx: ExtensionCommandContext,
457
+ rules: ReadonlyArray<HideRule>,
458
+ ): void {
459
+ const lines: string[] = [];
460
+
461
+ if (rules.length === 0) {
462
+ lines.push("No hide rules configured. Use /hide-models add to create rules.");
463
+ } else {
464
+ lines.push(`Hide rules (${rules.length}):`);
465
+ for (let i = 0; i < rules.length; i++) {
466
+ lines.push(` ${i + 1}. ${formatRule(rules[i])}`);
467
+ }
468
+ }
469
+
470
+ const registry = ctx.modelRegistry as unknown as PatchedRegistry;
471
+ if (registry[PATCH_KEY]) {
472
+ lines.push("");
473
+ lines.push("Status: PATCHED — getAvailable/getAll/find filter hidden models");
474
+ }
475
+
476
+ try {
477
+ const all = registry.__hide_providers_orig_getAll?.() ?? [];
478
+ if (all.length > 0) {
479
+ const hidden = (all as any[]).filter((m: any) => isHidden(rules, m.provider, m.id));
480
+ lines.push("");
481
+ lines.push(`Models: ${all.length - hidden.length} visible, ${hidden.length} hidden`);
482
+ if (hidden.length > 0) {
483
+ const preview = hidden.slice(0, 10);
484
+ for (const m of preview) {
485
+ lines.push(` ${m.provider}/${m.id}`);
486
+ }
487
+ if (hidden.length > 10) {
488
+ lines.push(` ... and ${hidden.length - 10} more`);
489
+ }
490
+ }
491
+ }
492
+ } catch {
493
+ // ignore
494
+ }
495
+
496
+ ctx.ui.notify(lines.join("\n"), "info");
497
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "pi-hide-providers",
3
+ "version": "0.1.0",
4
+ "description": "Hide providers and models from pi's model selector — filter the /model list and Ctrl+P cycling via a configurable blocklist",
5
+ "type": "module",
6
+ "author": "Tom X Nguyen",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/monotykamary/pi-hide-providers.git"
11
+ },
12
+ "homepage": "https://github.com/monotykamary/pi-hide-providers#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/monotykamary/pi-hide-providers/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi",
19
+ "pi-coding-agent",
20
+ "extension",
21
+ "hide",
22
+ "provider",
23
+ "model",
24
+ "filter",
25
+ "blocklist",
26
+ "model-selector"
27
+ ],
28
+ "files": [
29
+ "*.ts",
30
+ "src/",
31
+ "README.md"
32
+ ],
33
+ "scripts": {
34
+ "test": "vitest run",
35
+ "test:watch": "vitest",
36
+ "test:coverage": "vitest run --coverage",
37
+ "typecheck": "tsc --noEmit",
38
+ "lint:dead": "knip --no-gitignore"
39
+ },
40
+ "devDependencies": {
41
+ "@earendil-works/pi-coding-agent": "0.75.4",
42
+ "@types/node": "25.9.1",
43
+ "@vitest/coverage-v8": "4.1.7",
44
+ "knip": "6.14.1",
45
+ "typescript": "6.0.3",
46
+ "vitest": "4.1.7"
47
+ },
48
+ "pi": {
49
+ "extensions": [
50
+ "./hide-providers.ts"
51
+ ]
52
+ }
53
+ }
package/src/index.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shared constants, types, and utilities for pi-hide-providers.
3
+ */
4
+
5
+ /** Description shown in the / commands list. */
6
+ export const HIDE_COMMAND_DESCRIPTION = "Manage which models are hidden from the model selector";
7
+
8
+ /** Default config file name. */
9
+ export const CONFIG_FILENAME = "hide-providers.json";
10
+
11
+ /** Glob wildcard — matches any model id within a provider. */
12
+ export const PROVIDER_WILDCARD = "*";
13
+
14
+ export interface HideRule {
15
+ /** Provider name to hide (e.g. "ollama", "openrouter"). Required. */
16
+ provider: string;
17
+ /**
18
+ * Model id pattern to hide within the provider.
19
+ * Use "*" to hide all models from the provider.
20
+ * Omit or leave undefined to hide the entire provider.
21
+ */
22
+ model?: string;
23
+ }
24
+
25
+ export interface HideProvidersConfig {
26
+ /** List of hide rules. A model is hidden if it matches ANY rule. */
27
+ hide: HideRule[];
28
+ }
29
+
30
+ /**
31
+ * Check whether a model is matched by any hide rule.
32
+ *
33
+ * A rule matches when:
34
+ * - rule.provider === model.provider (exact, case-sensitive)
35
+ * - AND (rule.model is undefined OR rule.model === "*" OR rule.model === model.id)
36
+ */
37
+ export function isHidden(
38
+ rules: ReadonlyArray<HideRule>,
39
+ provider: string,
40
+ modelId: string,
41
+ ): boolean {
42
+ return rules.some(
43
+ (rule) =>
44
+ rule.provider === provider &&
45
+ (rule.model === undefined || rule.model === PROVIDER_WILDCARD || rule.model === modelId),
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Parse a provider/model reference string into a HideRule.
51
+ *
52
+ * Formats:
53
+ * "provider" → { provider } (hide entire provider)
54
+ * "provider/*" → { provider, model: "*" } (hide entire provider, explicit)
55
+ * "provider/model-id" → { provider, model: "model-id" } (hide specific model)
56
+ */
57
+ export function parseRule(input: string): HideRule | null {
58
+ const trimmed = input.trim();
59
+ if (trimmed.length === 0) return null;
60
+
61
+ const slashIndex = trimmed.indexOf("/");
62
+ if (slashIndex === -1) {
63
+ return { provider: trimmed };
64
+ }
65
+
66
+ const provider = trimmed.slice(0, slashIndex);
67
+ const model = trimmed.slice(slashIndex + 1);
68
+
69
+ if (provider.length === 0) return null;
70
+
71
+ return model === PROVIDER_WILDCARD
72
+ ? { provider }
73
+ : { provider, model };
74
+ }
75
+
76
+ /**
77
+ * Format a HideRule as a human-readable string.
78
+ */
79
+ export function formatRule(rule: HideRule): string {
80
+ if (rule.model === undefined || rule.model === PROVIDER_WILDCARD) {
81
+ return `${rule.provider}/*`;
82
+ }
83
+ return `${rule.provider}/${rule.model}`;
84
+ }
85
+
86
+ /**
87
+ * Deduplicate hide rules — same provider+model pair only kept once.
88
+ */
89
+ export function deduplicateRules(rules: ReadonlyArray<HideRule>): HideRule[] {
90
+ const seen = new Set<string>();
91
+ return rules.filter((rule) => {
92
+ const key = formatRule(rule);
93
+ if (seen.has(key)) return false;
94
+ seen.add(key);
95
+ return true;
96
+ });
97
+ }
@@ -0,0 +1,461 @@
1
+ /**
2
+ * HideProviderSelectorComponent — an interactive TUI for selecting which
3
+ * providers/models to hide from pi's model selector.
4
+ *
5
+ * Uses the same patterns as pi's built-in ScopedModelsSelectorComponent:
6
+ * - Lists all available models grouped by provider
7
+ * - Search/filter via Input component
8
+ * - Enter toggles hide/show for selected item
9
+ * - Tab toggles provider-level hide/show
10
+ * - Ctrl+A / Ctrl+D bulk hide/show (respects search filter)
11
+ * - Ctrl+S to save and close
12
+ * - Changes take effect immediately through the patched registry
13
+ * - Results are returned as HideRule[] array
14
+ */
15
+
16
+ import {
17
+ Container,
18
+ type Component,
19
+ fuzzyFilter,
20
+ getKeybindings,
21
+ Input,
22
+ Key,
23
+ matchesKey,
24
+ Spacer,
25
+ Text,
26
+ } from "@earendil-works/pi-tui";
27
+ import type { Theme } from "@earendil-works/pi-coding-agent";
28
+ import { DynamicBorder, keyText } from "@earendil-works/pi-coding-agent";
29
+ import {
30
+ type HideRule,
31
+ isHidden,
32
+ deduplicateRules,
33
+ } from "./index.js";
34
+
35
+ // Internal state for one display row
36
+
37
+ interface DisplayItem {
38
+ /** fullId = provider/id */
39
+ fullId: string;
40
+ provider: string;
41
+ modelId: string;
42
+ modelName: string;
43
+ hidden: boolean;
44
+ }
45
+
46
+ // Component
47
+
48
+ export interface HideProviderSelectorResult {
49
+ /** The final set of hide rules after the user closes the selector. */
50
+ rules: HideRule[];
51
+ /** If true, the user cancelled (esc) and rules should not be applied. */
52
+ cancelled: boolean;
53
+ }
54
+
55
+ export class HideProviderSelectorComponent implements Component {
56
+ // injected dependencies
57
+ private theme: Theme;
58
+ private done: (result: HideProviderSelectorResult) => void;
59
+
60
+ // model data
61
+ private allItems: DisplayItem[] = [];
62
+
63
+ // current hide rules
64
+ private hiddenRules: HideRule[] = [];
65
+
66
+ // UI state
67
+ private filteredItems: DisplayItem[] = [];
68
+ private selectedIndex = 0;
69
+ private maxVisible = 10;
70
+ private searchInput: Input;
71
+ private listContainer: Container;
72
+ private footerText: Text;
73
+ private hasChanges = false;
74
+
75
+ // Focusable — propagate to search input for IME cursor positioning
76
+ private _focused = false;
77
+ get focused(): boolean {
78
+ return this._focused;
79
+ }
80
+ set focused(value: boolean) {
81
+ this._focused = value;
82
+ this.searchInput.focused = value;
83
+ }
84
+
85
+ constructor(
86
+ theme: Theme,
87
+ allModels: Array<{ provider: string; id: string; name: string }>,
88
+ currentRules: HideRule[],
89
+ done: (result: HideProviderSelectorResult) => void,
90
+ ) {
91
+ this.theme = theme;
92
+ this.done = done;
93
+ this.hiddenRules = deduplicateRules(currentRules);
94
+
95
+ // Build display items
96
+ for (const m of allModels) {
97
+ this.allItems.push({
98
+ fullId: `${m.provider}/${m.id}`,
99
+ provider: m.provider,
100
+ modelId: m.id,
101
+ modelName: m.name,
102
+ hidden: isHidden(currentRules, m.provider, m.id),
103
+ });
104
+ }
105
+ this.filteredItems = [...this.allItems];
106
+
107
+ this.searchInput = new Input();
108
+ this.listContainer = new Container();
109
+ this.footerText = new Text(this.getFooterText(), 0, 0);
110
+
111
+ // Wire search input enter to toggle first visible item
112
+ this.searchInput.onSubmit = () => {
113
+ if (this.filteredItems[this.selectedIndex]) {
114
+ this.toggleItem(this.filteredItems[this.selectedIndex]);
115
+ }
116
+ };
117
+
118
+ this.updateList();
119
+ }
120
+
121
+ // Component interface
122
+
123
+ render(width: number): string[] {
124
+ const lines: string[] = [];
125
+
126
+ lines.push(...new DynamicBorder((s) => this.theme.fg("accent", s)).render(width));
127
+ lines.push("");
128
+ lines.push(this.theme.fg("accent", this.theme.bold("Hide Provider Configuration")));
129
+ lines.push(
130
+ this.theme.fg(
131
+ "muted",
132
+ `Select providers or models to hide from the model selector.`,
133
+ ),
134
+ );
135
+ lines.push("");
136
+ lines.push(...this.searchInput.render(width));
137
+ lines.push("");
138
+ lines.push(...this.listContainer.render(width));
139
+ lines.push("");
140
+ lines.push(...this.footerText.render(width));
141
+ lines.push(...new DynamicBorder((s) => this.theme.fg("accent", s)).render(width));
142
+
143
+ return lines;
144
+ }
145
+
146
+ handleInput(data: string): void {
147
+ const kb = getKeybindings();
148
+
149
+ if (kb.matches(data, "tui.select.up")) {
150
+ if (this.filteredItems.length === 0) return;
151
+ this.selectedIndex =
152
+ this.selectedIndex === 0
153
+ ? this.filteredItems.length - 1
154
+ : this.selectedIndex - 1;
155
+ this.updateList();
156
+ return;
157
+ }
158
+
159
+ if (kb.matches(data, "tui.select.down")) {
160
+ if (this.filteredItems.length === 0) return;
161
+ this.selectedIndex =
162
+ this.selectedIndex === this.filteredItems.length - 1
163
+ ? 0
164
+ : this.selectedIndex + 1;
165
+ this.updateList();
166
+ return;
167
+ }
168
+
169
+ // Tab — toggle the provider of the selected item
170
+ if (kb.matches(data, "tui.input.tab")) {
171
+ const item = this.filteredItems[this.selectedIndex];
172
+ if (item) {
173
+ this.toggleProvider(item.provider);
174
+ }
175
+ return;
176
+ }
177
+
178
+ // Enter — toggle selected item
179
+ if (kb.matches(data, "tui.select.confirm")) {
180
+ const item = this.filteredItems[this.selectedIndex];
181
+ if (item) {
182
+ this.toggleItem(item);
183
+ }
184
+ return;
185
+ }
186
+
187
+ // Ctrl+A — hide all (filtered if search active)
188
+ if (matchesKey(data, Key.ctrl("a"))) {
189
+ const targets = this.getFilterTargets();
190
+ this.hideModels(targets);
191
+ this.hasChanges = true;
192
+ this.refresh();
193
+ return;
194
+ }
195
+
196
+ // Ctrl+D — show all (filtered if search active)
197
+ if (matchesKey(data, Key.ctrl("d"))) {
198
+ const targets = this.getFilterTargets();
199
+ this.showModels(targets);
200
+ this.hasChanges = true;
201
+ this.refresh();
202
+ return;
203
+ }
204
+
205
+ // Ctrl+S — save and close
206
+ if (matchesKey(data, Key.ctrl("s"))) {
207
+ this.finish(false);
208
+ return;
209
+ }
210
+
211
+ // Escape — cancel
212
+ if (matchesKey(data, Key.escape)) {
213
+ this.finish(true);
214
+ return;
215
+ }
216
+
217
+ // Ctrl+C — clear search or cancel if empty
218
+ if (matchesKey(data, Key.ctrl("c"))) {
219
+ if (this.searchInput.getValue()) {
220
+ this.searchInput.setValue("");
221
+ this.refresh();
222
+ } else {
223
+ this.finish(true);
224
+ }
225
+ return;
226
+ }
227
+
228
+ // Pass everything else to search input
229
+ this.searchInput.handleInput(data);
230
+ this.refresh();
231
+ }
232
+
233
+ invalidate(): void {
234
+ this.searchInput.invalidate();
235
+ this.listContainer.invalidate();
236
+ this.footerText.invalidate();
237
+ }
238
+
239
+ // Internal helpers
240
+
241
+ private getFilterTargets(): DisplayItem[] {
242
+ const query = this.searchInput.getValue();
243
+ return query ? this.filteredItems : this.allItems;
244
+ }
245
+
246
+ private getFooterText(): string {
247
+ const allCount = this.allItems.length;
248
+ const hiddenCount = this.allItems.filter(
249
+ (i) => isHidden(this.hiddenRules, i.provider, i.modelId),
250
+ ).length;
251
+ const visibleCount = allCount - hiddenCount;
252
+
253
+ const parts: string[] = [
254
+ `${keyText("tui.select.confirm")} toggle`,
255
+ `tab provider`,
256
+ `ctrl+a hide all`,
257
+ `ctrl+d show all`,
258
+ `ctrl+s done`,
259
+ `${visibleCount} visible · ${hiddenCount} hidden`,
260
+ ];
261
+
262
+ const text = parts.join(" · ");
263
+ return this.hasChanges
264
+ ? this.theme.fg("dim", ` ${text} `) + this.theme.fg("warning", "(unsaved)")
265
+ : this.theme.fg("dim", ` ${text}`);
266
+ }
267
+
268
+ private refresh(): void {
269
+ const query = this.searchInput.getValue();
270
+ this.filteredItems = query
271
+ ? fuzzyFilter(
272
+ this.allItems,
273
+ query,
274
+ (i) => `${i.provider} ${i.modelId} ${i.provider}/${i.modelId} ${i.modelName}`,
275
+ )
276
+ : [...this.allItems];
277
+
278
+ // Update hidden status on all items (rules may have changed)
279
+ for (const item of this.filteredItems) {
280
+ item.hidden = isHidden(this.hiddenRules, item.provider, item.modelId);
281
+ }
282
+
283
+ this.selectedIndex = Math.min(
284
+ this.selectedIndex,
285
+ Math.max(0, this.filteredItems.length - 1),
286
+ );
287
+ this.updateList();
288
+ }
289
+
290
+ private updateList(): void {
291
+ this.listContainer.clear();
292
+
293
+ if (this.filteredItems.length === 0) {
294
+ this.listContainer.addChild(
295
+ new Text(this.theme.fg("muted", " No matching models"), 0, 0),
296
+ );
297
+ this.footerText.setText(this.getFooterText());
298
+ return;
299
+ }
300
+
301
+ const startIndex = Math.max(
302
+ 0,
303
+ Math.min(
304
+ this.selectedIndex - Math.floor(this.maxVisible / 2),
305
+ this.filteredItems.length - this.maxVisible,
306
+ ),
307
+ );
308
+ const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
309
+
310
+ for (let i = startIndex; i < endIndex; i++) {
311
+ const item = this.filteredItems[i];
312
+ if (!item) continue;
313
+
314
+ const isSelected = i === this.selectedIndex;
315
+ const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
316
+ const modelText = isSelected
317
+ ? this.theme.fg("accent", item.modelId)
318
+ : item.modelId;
319
+ const providerBadge = this.theme.fg("muted", ` [${item.provider}]`);
320
+ const status = item.hidden
321
+ ? this.theme.fg("warning", " ✗")
322
+ : this.theme.fg("success", " ✓");
323
+
324
+ this.listContainer.addChild(
325
+ new Text(`${prefix}${modelText}${providerBadge}${status}`, 0, 0),
326
+ );
327
+ }
328
+
329
+ // Scroll indicator
330
+ if (startIndex > 0 || endIndex < this.filteredItems.length) {
331
+ this.listContainer.addChild(
332
+ new Text(
333
+ this.theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`),
334
+ 0,
335
+ 0,
336
+ ),
337
+ );
338
+ }
339
+
340
+ // Model name + provider status for the selected item
341
+ if (this.filteredItems.length > 0) {
342
+ const selected = this.filteredItems[this.selectedIndex];
343
+ this.listContainer.addChild(new Spacer(1));
344
+ this.listContainer.addChild(
345
+ new Text(this.theme.fg("muted", ` Model Name: ${selected.modelName}`), 0, 0),
346
+ );
347
+
348
+ const providerItems = this.allItems.filter(
349
+ (i) => i.provider === selected.provider,
350
+ );
351
+ const hiddenInProvider = providerItems.filter(
352
+ (i) => isHidden(this.hiddenRules, i.provider, i.modelId),
353
+ ).length;
354
+ this.listContainer.addChild(
355
+ new Text(
356
+ this.theme.fg(
357
+ "dim",
358
+ ` Provider: ${selected.provider} — ${providerItems.length - hiddenInProvider} visible, ${hiddenInProvider} hidden`,
359
+ ),
360
+ 0,
361
+ 0,
362
+ ),
363
+ );
364
+ }
365
+
366
+ this.footerText.setText(this.getFooterText());
367
+ }
368
+
369
+ /** Toggle a single item between hidden and visible. */
370
+ private toggleItem(item: DisplayItem): void {
371
+ if (item.hidden) {
372
+ // Remove the matching rule
373
+ this.hiddenRules = this.hiddenRules.filter(
374
+ (r) =>
375
+ !(r.provider === item.provider &&
376
+ (r.model === item.modelId || r.model === undefined)),
377
+ );
378
+ } else {
379
+ // Add a rule for this specific model
380
+ this.hiddenRules = deduplicateRules([
381
+ ...this.hiddenRules,
382
+ { provider: item.provider, model: item.modelId },
383
+ ]);
384
+ }
385
+ this.hasChanges = true;
386
+ this.refresh();
387
+ }
388
+
389
+ /** Toggle all models for a provider between hidden and visible. */
390
+ private toggleProvider(provider: string): void {
391
+ const providerItems = this.allItems.filter((i) => i.provider === provider);
392
+ const hiddenCount = providerItems.filter(
393
+ (i) => isHidden(this.hiddenRules, i.provider, i.modelId),
394
+ ).length;
395
+
396
+ if (hiddenCount > 0) {
397
+ // Show all — remove all rules for this provider
398
+ this.hiddenRules = this.hiddenRules.filter(
399
+ (r) => r.provider !== provider,
400
+ );
401
+ } else {
402
+ // Hide all — add a single provider-level rule
403
+ this.hiddenRules = deduplicateRules([
404
+ ...this.hiddenRules,
405
+ { provider },
406
+ ]);
407
+ }
408
+ this.hasChanges = true;
409
+ this.refresh();
410
+ }
411
+
412
+ /** Add hide rules for the given items. */
413
+ private hideModels(items: DisplayItem[]): void {
414
+ const byProvider = new Map<string, string[]>();
415
+ for (const item of items) {
416
+ const list = byProvider.get(item.provider) ?? [];
417
+ list.push(item.modelId);
418
+ byProvider.set(item.provider, list);
419
+ }
420
+
421
+ for (const [provider, modelIds] of byProvider) {
422
+ const totalForProvider = this.allItems.filter(
423
+ (i) => i.provider === provider,
424
+ ).length;
425
+ if (modelIds.length === totalForProvider) {
426
+ // All models for this provider — use a provider-level rule
427
+ this.hiddenRules = deduplicateRules([
428
+ ...this.hiddenRules.filter((r) => r.provider !== provider),
429
+ { provider },
430
+ ]);
431
+ } else {
432
+ // Partial — add individual model rules
433
+ for (const modelId of modelIds) {
434
+ this.hiddenRules = deduplicateRules([
435
+ ...this.hiddenRules,
436
+ { provider, model: modelId },
437
+ ]);
438
+ }
439
+ }
440
+ }
441
+ }
442
+
443
+ /** Remove hide rules for the given items. */
444
+ private showModels(items: DisplayItem[]): void {
445
+ for (const item of items) {
446
+ this.hiddenRules = this.hiddenRules.filter(
447
+ (r) =>
448
+ !(r.provider === item.provider &&
449
+ (r.model === item.modelId || r.model === undefined)),
450
+ );
451
+ }
452
+ }
453
+
454
+ /** Close and pass results to `done`. */
455
+ private finish(cancelled: boolean): void {
456
+ this.done({
457
+ rules: cancelled ? [] : this.hiddenRules,
458
+ cancelled,
459
+ });
460
+ }
461
+ }
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: "node",
7
+ include: ["__tests__/**/*.test.ts"],
8
+ exclude: ["node_modules", "dist", ".idea", ".git", ".cache"],
9
+ coverage: {
10
+ provider: "v8",
11
+ reporter: ["text", "json", "html"],
12
+ exclude: ["node_modules/", "**/*.d.ts", "**/*.test.ts"],
13
+ },
14
+ },
15
+ });