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 +165 -0
- package/hide-providers.ts +497 -0
- package/package.json +53 -0
- package/src/index.ts +97 -0
- package/src/provider-selector.ts +461 -0
- package/vitest.config.ts +15 -0
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
|
+
[](https://github.com/earendil-works/pi-coding-agent)
|
|
10
|
+
[](./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
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -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
|
+
});
|