@nicknisi/pi-model-switch 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/LICENSE +21 -0
- package/README.md +148 -0
- package/config.ts +131 -0
- package/cycle.ts +83 -0
- package/index.ts +114 -0
- package/model-switch.example.json +11 -0
- package/package.json +43 -0
- package/section-picker.ts +100 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nick Nisi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# @nicknisi/pi-model-switch
|
|
2
|
+
|
|
3
|
+
Cycle or fuzzy-pick from a machine-local, sectioned list of preferred Pi models.
|
|
4
|
+
|
|
5
|
+
This package is useful when the same Pi configuration is shared across machines but each machine has different providers, credentials, or model preferences. The extension reads an untracked local config instead of using `enabledModels`, skips unavailable entries, and leaves Pi's normal `/model` and Ctrl+L picker unchanged.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
From npm after the package is published:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pi install npm:@nicknisi/pi-model-switch
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
From a local checkout:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install ~/Developer/pi-extensions/packages/model-switch
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Configure shortcuts
|
|
22
|
+
|
|
23
|
+
The defaults are Ctrl+Shift+M forward, Ctrl+Shift+Alt+M backward, and Ctrl+Shift+L for the fuzzy picker. Override any of them in Pi's existing `~/.pi/agent/keybindings.json`:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"model-switch.cycleForward": "ctrl+shift+m",
|
|
28
|
+
"model-switch.cycleBackward": "ctrl+shift+alt+m",
|
|
29
|
+
"model-switch.select": "ctrl+shift+l"
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
These extension-owned action IDs are ignored by Pi's built-in keybinding manager and read by model-switch during extension load. Run `/reload` or restart Pi after changing them. Each value must be one non-empty Pi key string; a missing or invalid value falls back to its default.
|
|
34
|
+
|
|
35
|
+
## Configure models
|
|
36
|
+
|
|
37
|
+
Copy the example config:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
mkdir -p ~/.pi/agent/configs
|
|
41
|
+
cp model-switch.example.json ~/.pi/agent/configs/model-switch.json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or create `~/.pi/agent/configs/model-switch.json` directly with named sections:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"sections": {
|
|
49
|
+
"work": ["cloudflare-ai-gateway/gpt-5.6-sol", "cloudflare-ai-gateway/claude-opus-5"],
|
|
50
|
+
"personal": ["fireworks/accounts/fireworks/models/kimi-k3"]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Section names are arbitrary — define as many as you want. The legacy flat format also works:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"models": ["cloudflare-ai-gateway/gpt-5.6-sol"]
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The file is read on every interaction, so edits take effect without reloading Pi.
|
|
64
|
+
|
|
65
|
+
### Config schema
|
|
66
|
+
|
|
67
|
+
| Field | Type | Default | Description |
|
|
68
|
+
| ---------- | -------------------------- | ------- | ---------------------------------------------------------------------------------- |
|
|
69
|
+
| `sections` | `Record<string, string[]>` | — | Named sections of ordered `provider/model-id` references. Preferred over `models`. |
|
|
70
|
+
| `models` | `string[]` | — | Legacy flat list. Treated as a single unnamed section when `sections` is absent. |
|
|
71
|
+
|
|
72
|
+
The first `/` separates the provider from the model ID. Additional slashes belong to the model ID, so references such as `fireworks/accounts/fireworks/models/kimi-k3` work as expected.
|
|
73
|
+
|
|
74
|
+
Keep this config machine-local and untracked. A personal machine and work machine can each provide their own sections while sharing the installed extension and the rest of your Pi dotfiles.
|
|
75
|
+
|
|
76
|
+
## Behavior
|
|
77
|
+
|
|
78
|
+
### Cycling
|
|
79
|
+
|
|
80
|
+
- **Ctrl+Shift+M** moves forward through usable models in the active section.
|
|
81
|
+
- **Ctrl+Shift+Alt+M** moves backward.
|
|
82
|
+
- The active section is the one containing the current model. If the current model is not in any section, the first section is used.
|
|
83
|
+
- Cycling wraps at both ends within the active section.
|
|
84
|
+
|
|
85
|
+
### Fuzzy picker
|
|
86
|
+
|
|
87
|
+
- **Ctrl+Shift+L** or `/model-switch` opens a fuzzy-searchable TUI picker.
|
|
88
|
+
- **Tab** cycles between sections within the picker.
|
|
89
|
+
- Type to fuzzy-filter models within the active section.
|
|
90
|
+
- The picker preserves config order, marks the current model with `●`, switches the chosen entry, and treats cancellation as a no-op.
|
|
91
|
+
- Sections with no usable models are hidden from the picker.
|
|
92
|
+
|
|
93
|
+
### General
|
|
94
|
+
|
|
95
|
+
- Missing models and models without working provider authentication are skipped while preserving config order.
|
|
96
|
+
- If no configured model is usable, Pi keeps the current model and shows a warning.
|
|
97
|
+
- Invalid JSON or invalid config values produce a warning containing the config path.
|
|
98
|
+
- The extension never changes the model at startup.
|
|
99
|
+
|
|
100
|
+
`enabledModels` is not read or modified. Pi's `/model` command and Ctrl+L picker continue to expose the normal available catalog.
|
|
101
|
+
|
|
102
|
+
## Troubleshooting
|
|
103
|
+
|
|
104
|
+
### The custom shortcuts do not respond
|
|
105
|
+
|
|
106
|
+
Check the `model-switch.cycleForward`, `model-switch.cycleBackward`, and `model-switch.select` values in `~/.pi/agent/keybindings.json`, then run `/reload`. Check Pi's startup diagnostics for another extension using the same physical keys, and verify that your terminal reports the configured combinations as distinct modified key events. No built-in Pi keybinding changes are required.
|
|
107
|
+
|
|
108
|
+
### A configured model is skipped
|
|
109
|
+
|
|
110
|
+
Check that Pi knows the exact reference and that its provider is authenticated:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pi --list-models
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Model entries must use the full `provider/model-id` shown by Pi. Authentication is machine-specific and can be managed with `/login` and `/logout`.
|
|
117
|
+
|
|
118
|
+
### No configured models are available
|
|
119
|
+
|
|
120
|
+
The config may be missing, empty, malformed, or contain only missing/unauthenticated entries. Pi's warning includes the active config path. `PI_CODING_AGENT_DIR` changes that path along with the rest of the agent configuration.
|
|
121
|
+
|
|
122
|
+
## Caveats
|
|
123
|
+
|
|
124
|
+
- This extension owns only its custom cycle and fuzzy picker; it cannot and does not mutate Pi's read-only scoped model list.
|
|
125
|
+
- The picker uses a custom search + list component with `ctx.ui.custom()`; native `/model` and Ctrl+L remain available for the full catalog.
|
|
126
|
+
- Terminal support for multi-modifier keys varies; configure simpler non-conflicting keys or use a terminal with the Kitty keyboard protocol when modified keys are not distinguishable.
|
|
127
|
+
- Availability is checked on each interaction, which may resolve provider credentials before switching.
|
|
128
|
+
- Duplicate references within a section are preserved as written; avoid them unless repeated cycle positions are intentional.
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
From the repository root:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pnpm vitest run packages/model-switch
|
|
136
|
+
pnpm typecheck
|
|
137
|
+
pnpm lint
|
|
138
|
+
pnpm format:check
|
|
139
|
+
pnpm build
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Smoke-test extension loading with a scratch agent directory:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
tmp=$(mktemp -d)
|
|
146
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
147
|
+
PI_CODING_AGENT_DIR="$tmp" pi --no-extensions -e packages/model-switch/index.ts --list-models
|
|
148
|
+
```
|
package/config.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
4
|
+
|
|
5
|
+
export interface ModelSwitchSection {
|
|
6
|
+
name: string;
|
|
7
|
+
models: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ModelSwitchConfig {
|
|
11
|
+
sections: ModelSwitchSection[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ModelSwitchKeybindings {
|
|
15
|
+
forward: string;
|
|
16
|
+
backward: string;
|
|
17
|
+
select: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_MODEL_CYCLE_KEYBINDINGS: ModelSwitchKeybindings = {
|
|
21
|
+
forward: 'ctrl+shift+m',
|
|
22
|
+
backward: 'ctrl+shift+alt+m',
|
|
23
|
+
select: 'ctrl+shift+l',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const FORWARD_KEYBINDING = 'model-switch.cycleForward';
|
|
27
|
+
const BACKWARD_KEYBINDING = 'model-switch.cycleBackward';
|
|
28
|
+
const SELECT_KEYBINDING = 'model-switch.select';
|
|
29
|
+
|
|
30
|
+
export type ConfigLoadResult = { ok: true; config: ModelSwitchConfig } | { ok: false; error: string };
|
|
31
|
+
|
|
32
|
+
export function modelCycleConfigPath(): string {
|
|
33
|
+
return join(getAgentDir(), 'configs', 'model-switch.json');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function modelCycleKeybindingsPath(): string {
|
|
37
|
+
return join(getAgentDir(), 'keybindings.json');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function loadModelSwitchKeybindings(path = modelCycleKeybindingsPath()): ModelSwitchKeybindings {
|
|
41
|
+
if (!existsSync(path)) return { ...DEFAULT_MODEL_CYCLE_KEYBINDINGS };
|
|
42
|
+
|
|
43
|
+
let value: unknown;
|
|
44
|
+
try {
|
|
45
|
+
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return { ...DEFAULT_MODEL_CYCLE_KEYBINDINGS };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!value || typeof value !== 'object') return { ...DEFAULT_MODEL_CYCLE_KEYBINDINGS };
|
|
51
|
+
|
|
52
|
+
const bindings = value as Record<string, unknown>;
|
|
53
|
+
const forward = bindings[FORWARD_KEYBINDING];
|
|
54
|
+
const backward = bindings[BACKWARD_KEYBINDING];
|
|
55
|
+
const select = bindings[SELECT_KEYBINDING];
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
forward:
|
|
59
|
+
typeof forward === 'string' && forward.trim().length > 0
|
|
60
|
+
? forward.trim()
|
|
61
|
+
: DEFAULT_MODEL_CYCLE_KEYBINDINGS.forward,
|
|
62
|
+
backward:
|
|
63
|
+
typeof backward === 'string' && backward.trim().length > 0
|
|
64
|
+
? backward.trim()
|
|
65
|
+
: DEFAULT_MODEL_CYCLE_KEYBINDINGS.backward,
|
|
66
|
+
select:
|
|
67
|
+
typeof select === 'string' && select.trim().length > 0 ? select.trim() : DEFAULT_MODEL_CYCLE_KEYBINDINGS.select,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateModelStrings(raw: unknown, path: string, context: string): string[] | { error: string } {
|
|
72
|
+
if (!Array.isArray(raw)) {
|
|
73
|
+
return { error: `Invalid model-switch config at ${path}: expected "${context}" to be a string[]` };
|
|
74
|
+
}
|
|
75
|
+
if (raw.some((model) => typeof model !== 'string' || model.trim().length === 0)) {
|
|
76
|
+
return { error: `Invalid model-switch config at ${path}: every model in "${context}" must be a non-empty string` };
|
|
77
|
+
}
|
|
78
|
+
return raw.map((model) => model.trim());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function loadModelSwitchConfig(path = modelCycleConfigPath()): ConfigLoadResult {
|
|
82
|
+
if (!existsSync(path)) {
|
|
83
|
+
return { ok: true, config: { sections: [] } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let value: unknown;
|
|
87
|
+
try {
|
|
88
|
+
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
91
|
+
return { ok: false, error: `Invalid model-switch config at ${path}: ${message}` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!value || typeof value !== 'object') {
|
|
95
|
+
return { ok: false, error: `Invalid model-switch config at ${path}: expected an object` };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const obj = value as Record<string, unknown>;
|
|
99
|
+
|
|
100
|
+
// Prefer "sections" if present; fall back to legacy "models" as a single section.
|
|
101
|
+
if ('sections' in obj && obj.sections && typeof obj.sections === 'object') {
|
|
102
|
+
const sectionsRaw = obj.sections as Record<string, unknown>;
|
|
103
|
+
const sections: ModelSwitchSection[] = [];
|
|
104
|
+
|
|
105
|
+
for (const [name, modelsRaw] of Object.entries(sectionsRaw)) {
|
|
106
|
+
const result = validateModelStrings(modelsRaw, path, `sections.${name}`);
|
|
107
|
+
if (!Array.isArray(result)) return { ok: false, error: result.error };
|
|
108
|
+
sections.push({ name, models: result });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (sections.length === 0) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: `Invalid model-switch config at ${path}: "sections" must define at least one section`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { ok: true, config: { sections } };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if ('models' in obj) {
|
|
122
|
+
const result = validateModelStrings(obj.models, path, 'models');
|
|
123
|
+
if (!Array.isArray(result)) return { ok: false, error: result.error };
|
|
124
|
+
return { ok: true, config: { sections: [{ name: 'models', models: result }] } };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
error: `Invalid model-switch config at ${path}: expected { "sections": { ... } } or { "models": [...] }`,
|
|
130
|
+
};
|
|
131
|
+
}
|
package/cycle.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { ModelSwitchSection } from './config.js';
|
|
3
|
+
|
|
4
|
+
export type CycleDirection = 'forward' | 'backward';
|
|
5
|
+
|
|
6
|
+
export interface ModelReference {
|
|
7
|
+
provider: string;
|
|
8
|
+
modelId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ModelRegistryLike {
|
|
12
|
+
find(provider: string, modelId: string): Model<Api> | undefined;
|
|
13
|
+
getApiKeyAndHeaders(
|
|
14
|
+
model: Model<Api>,
|
|
15
|
+
): Promise<{ ok: true; apiKey?: string; headers?: Record<string, string | null> } | { ok: false; error: string }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseModelReference(value: string): ModelReference | undefined {
|
|
19
|
+
const separator = value.indexOf('/');
|
|
20
|
+
if (separator <= 0 || separator === value.length - 1) return undefined;
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
provider: value.slice(0, separator),
|
|
24
|
+
modelId: value.slice(separator + 1),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function resolveAvailableModels(
|
|
29
|
+
references: readonly string[],
|
|
30
|
+
registry: ModelRegistryLike,
|
|
31
|
+
): Promise<Model<Api>[]> {
|
|
32
|
+
const available: Model<Api>[] = [];
|
|
33
|
+
|
|
34
|
+
for (const value of references) {
|
|
35
|
+
const reference = parseModelReference(value);
|
|
36
|
+
if (!reference) continue;
|
|
37
|
+
|
|
38
|
+
const model = registry.find(reference.provider, reference.modelId);
|
|
39
|
+
if (!model) continue;
|
|
40
|
+
|
|
41
|
+
const auth = await registry.getApiKeyAndHeaders(model);
|
|
42
|
+
if (auth.ok) available.push(model);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return available;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function findActiveSection(
|
|
49
|
+
sections: readonly ModelSwitchSection[],
|
|
50
|
+
current: Model<Api> | undefined,
|
|
51
|
+
): ModelSwitchSection | undefined {
|
|
52
|
+
if (sections.length === 0) return undefined;
|
|
53
|
+
if (!current) return sections[0];
|
|
54
|
+
|
|
55
|
+
const found = sections.find((section) =>
|
|
56
|
+
section.models.some((value) => {
|
|
57
|
+
const ref = parseModelReference(value);
|
|
58
|
+
return ref?.provider === current.provider && ref?.modelId === current.id;
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return found ?? sections[0];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function selectCycleTarget(
|
|
66
|
+
current: Model<Api> | undefined,
|
|
67
|
+
available: readonly Model<Api>[],
|
|
68
|
+
direction: CycleDirection,
|
|
69
|
+
): Model<Api> | undefined {
|
|
70
|
+
if (available.length === 0) return undefined;
|
|
71
|
+
|
|
72
|
+
const currentIndex = current
|
|
73
|
+
? available.findIndex((model) => model.provider === current.provider && model.id === current.id)
|
|
74
|
+
: -1;
|
|
75
|
+
|
|
76
|
+
if (currentIndex === -1) {
|
|
77
|
+
return direction === 'forward' ? available[0] : available[available.length - 1];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const offset = direction === 'forward' ? 1 : -1;
|
|
81
|
+
const nextIndex = (currentIndex + offset + available.length) % available.length;
|
|
82
|
+
return available[nextIndex];
|
|
83
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import type { SelectItem } from '@earendil-works/pi-tui';
|
|
4
|
+
import { loadModelSwitchConfig, loadModelSwitchKeybindings, modelCycleConfigPath } from './config.js';
|
|
5
|
+
import { findActiveSection, resolveAvailableModels, selectCycleTarget, type CycleDirection } from './cycle.js';
|
|
6
|
+
import { SectionPicker, type PickerSection } from './section-picker.js';
|
|
7
|
+
|
|
8
|
+
async function resolveSectionModels(references: readonly string[], ctx: ExtensionContext): Promise<Model<Api>[]> {
|
|
9
|
+
return resolveAvailableModels(references, ctx.modelRegistry);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function switchModel(pi: ExtensionAPI, ctx: ExtensionContext, target: Model<Api>): Promise<void> {
|
|
13
|
+
if (!(await pi.setModel(target))) {
|
|
14
|
+
ctx.ui.notify(`Could not switch to ${target.provider}/${target.id}`, 'warning');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function cycleConfiguredModel(pi: ExtensionAPI, ctx: ExtensionContext, direction: CycleDirection): Promise<void> {
|
|
19
|
+
const loaded = loadModelSwitchConfig();
|
|
20
|
+
if (!loaded.ok) {
|
|
21
|
+
ctx.ui.notify(loaded.error, 'warning');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const activeSection = findActiveSection(loaded.config.sections, ctx.model);
|
|
26
|
+
if (!activeSection) {
|
|
27
|
+
ctx.ui.notify(`No configured models are available in ${modelCycleConfigPath()}`, 'warning');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const available = await resolveSectionModels(activeSection.models, ctx);
|
|
32
|
+
if (available.length === 0) {
|
|
33
|
+
ctx.ui.notify(`No usable models in section "${activeSection.name}" (${modelCycleConfigPath()})`, 'warning');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const target = selectCycleTarget(ctx.model, available, direction);
|
|
38
|
+
if (target) await switchModel(pi, ctx, target);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildSectionItems(models: Model<Api>[], current: Model<Api> | undefined): SelectItem[] {
|
|
42
|
+
return models.map((model) => {
|
|
43
|
+
const isCurrent = model.provider === current?.provider && model.id === current.id;
|
|
44
|
+
return {
|
|
45
|
+
value: `${model.provider}/${model.id}`,
|
|
46
|
+
label: `${isCurrent ? '●' : ' '} ${model.provider}/${model.id}`,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function showModelPicker(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
52
|
+
if (!ctx.hasUI) return;
|
|
53
|
+
|
|
54
|
+
const loaded = loadModelSwitchConfig();
|
|
55
|
+
if (!loaded.ok) {
|
|
56
|
+
ctx.ui.notify(loaded.error, 'warning');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const pickerSections: PickerSection[] = [];
|
|
61
|
+
const modelByReference = new Map<string, Model<Api>>();
|
|
62
|
+
|
|
63
|
+
for (const section of loaded.config.sections) {
|
|
64
|
+
const available = await resolveSectionModels(section.models, ctx);
|
|
65
|
+
if (available.length > 0) {
|
|
66
|
+
pickerSections.push({
|
|
67
|
+
name: section.name,
|
|
68
|
+
items: buildSectionItems(available, ctx.model),
|
|
69
|
+
});
|
|
70
|
+
for (const model of available) {
|
|
71
|
+
modelByReference.set(`${model.provider}/${model.id}`, model);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (pickerSections.length === 0) {
|
|
77
|
+
ctx.ui.notify(`No configured models are available in ${modelCycleConfigPath()}`, 'warning');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const selected = await ctx.ui.custom<string | null>((tui, theme, _keybindings, done) => {
|
|
82
|
+
return new SectionPicker(pickerSections, theme, done);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!selected) return;
|
|
86
|
+
|
|
87
|
+
const target = modelByReference.get(selected);
|
|
88
|
+
if (target) await switchModel(pi, ctx, target);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export default function modelCycle(pi: ExtensionAPI) {
|
|
92
|
+
const keybindings = loadModelSwitchKeybindings();
|
|
93
|
+
type ShortcutKey = Parameters<ExtensionAPI['registerShortcut']>[0];
|
|
94
|
+
|
|
95
|
+
pi.registerShortcut(keybindings.forward as ShortcutKey, {
|
|
96
|
+
description: 'Cycle configured models forward',
|
|
97
|
+
handler: async (ctx) => cycleConfiguredModel(pi, ctx, 'forward'),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
pi.registerShortcut(keybindings.backward as ShortcutKey, {
|
|
101
|
+
description: 'Cycle configured models backward',
|
|
102
|
+
handler: async (ctx) => cycleConfiguredModel(pi, ctx, 'backward'),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
pi.registerShortcut(keybindings.select as ShortcutKey, {
|
|
106
|
+
description: 'Select a configured model',
|
|
107
|
+
handler: async (ctx) => showModelPicker(pi, ctx),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
pi.registerCommand('model-switch', {
|
|
111
|
+
description: 'Select from configured models',
|
|
112
|
+
handler: async (_args, ctx) => showModelPicker(pi, ctx),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"sections": {
|
|
3
|
+
"work": [
|
|
4
|
+
"cloudflare-ai-gateway/gpt-5.6-sol",
|
|
5
|
+
"cloudflare-ai-gateway/claude-opus-5",
|
|
6
|
+
"cloudflare-ai-gateway/grok-4.5",
|
|
7
|
+
"fireworks/accounts/fireworks/models/kimi-k3"
|
|
8
|
+
],
|
|
9
|
+
"personal": ["fireworks/accounts/fireworks/models/kimi-k3"]
|
|
10
|
+
}
|
|
11
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nicknisi/pi-model-switch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cycle or fuzzy-pick from a machine-local, sectioned list of preferred Pi models",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi",
|
|
7
|
+
"pi-coding-agent",
|
|
8
|
+
"pi-package"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://github.com/nicknisi/pi-extensions/tree/main/packages/model-switch#readme",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/nicknisi/pi-extensions.git",
|
|
15
|
+
"directory": "packages/model-switch"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"index.ts",
|
|
20
|
+
"config.ts",
|
|
21
|
+
"cycle.ts",
|
|
22
|
+
"section-picker.ts",
|
|
23
|
+
"README.md",
|
|
24
|
+
"model-switch.example.json"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@earendil-works/pi-ai": "*",
|
|
35
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
36
|
+
"@earendil-works/pi-tui": "*"
|
|
37
|
+
},
|
|
38
|
+
"pi": {
|
|
39
|
+
"extensions": [
|
|
40
|
+
"./index.ts"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Container,
|
|
3
|
+
Input,
|
|
4
|
+
SelectList,
|
|
5
|
+
Text,
|
|
6
|
+
getKeybindings,
|
|
7
|
+
matchesKey,
|
|
8
|
+
type Component,
|
|
9
|
+
type SelectItem,
|
|
10
|
+
type SelectListTheme,
|
|
11
|
+
} from '@earendil-works/pi-tui';
|
|
12
|
+
import type { Theme } from '@earendil-works/pi-coding-agent';
|
|
13
|
+
|
|
14
|
+
export interface PickerSection {
|
|
15
|
+
name: string;
|
|
16
|
+
items: SelectItem[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class SectionPicker implements Component {
|
|
20
|
+
private container = new Container();
|
|
21
|
+
private tabBar = new Text('', 0, 0);
|
|
22
|
+
private searchInput = new Input();
|
|
23
|
+
private selectList: SelectList;
|
|
24
|
+
private activeSectionIndex = 0;
|
|
25
|
+
private readonly selectTheme: SelectListTheme;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly sections: PickerSection[],
|
|
29
|
+
private readonly theme: Theme,
|
|
30
|
+
private readonly done: (value: string | null) => void,
|
|
31
|
+
) {
|
|
32
|
+
this.selectTheme = {
|
|
33
|
+
selectedPrefix: (text) => theme.fg('accent', text),
|
|
34
|
+
selectedText: (text) => theme.fg('accent', text),
|
|
35
|
+
description: (text) => theme.fg('muted', text),
|
|
36
|
+
scrollInfo: (text) => theme.fg('dim', text),
|
|
37
|
+
noMatch: (text) => theme.fg('warning', text),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
this.selectList = new SelectList(sections[0]?.items ?? [], 10, this.selectTheme);
|
|
41
|
+
this.selectList.onSelect = (item) => this.done(item.value);
|
|
42
|
+
this.selectList.onCancel = () => this.done(null);
|
|
43
|
+
|
|
44
|
+
this.container.addChild(this.tabBar);
|
|
45
|
+
this.container.addChild(this.searchInput);
|
|
46
|
+
this.container.addChild(this.selectList);
|
|
47
|
+
this.renderTabBar();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private renderTabBar(): void {
|
|
51
|
+
const parts = this.sections.map((section, index) => {
|
|
52
|
+
const label = section.name;
|
|
53
|
+
return index === this.activeSectionIndex
|
|
54
|
+
? this.theme.fg('accent', this.theme.bold(`[${label}]`))
|
|
55
|
+
: this.theme.fg('dim', ` ${label} `);
|
|
56
|
+
});
|
|
57
|
+
this.tabBar.setText(parts.join(''));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private switchSection(direction: 1 | -1): void {
|
|
61
|
+
this.activeSectionIndex = (this.activeSectionIndex + direction + this.sections.length) % this.sections.length;
|
|
62
|
+
this.searchInput.setValue('');
|
|
63
|
+
this.selectList = new SelectList(this.sections[this.activeSectionIndex]?.items ?? [], 10, this.selectTheme);
|
|
64
|
+
this.selectList.onSelect = (item) => this.done(item.value);
|
|
65
|
+
this.selectList.onCancel = () => this.done(null);
|
|
66
|
+
this.container.clear();
|
|
67
|
+
this.container.addChild(this.tabBar);
|
|
68
|
+
this.container.addChild(this.searchInput);
|
|
69
|
+
this.container.addChild(this.selectList);
|
|
70
|
+
this.renderTabBar();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
render(width: number): string[] {
|
|
74
|
+
return this.container.render(width);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
invalidate(): void {
|
|
78
|
+
this.container.invalidate();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
handleInput(data: string): void {
|
|
82
|
+
if (matchesKey(data, 'tab')) {
|
|
83
|
+
this.switchSection(1);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const kb = getKeybindings();
|
|
88
|
+
if (
|
|
89
|
+
kb.matches(data, 'tui.select.up') ||
|
|
90
|
+
kb.matches(data, 'tui.select.down') ||
|
|
91
|
+
kb.matches(data, 'tui.select.confirm') ||
|
|
92
|
+
kb.matches(data, 'tui.select.cancel')
|
|
93
|
+
) {
|
|
94
|
+
this.selectList.handleInput(data);
|
|
95
|
+
} else {
|
|
96
|
+
this.searchInput.handleInput(data);
|
|
97
|
+
this.selectList.setFilter(this.searchInput.getValue());
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|