pi-model-control 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 +22 -0
- package/extensions/thinking.ts +48 -0
- package/extensions/variants.ts +278 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# pi-model-control
|
|
2
|
+
|
|
3
|
+
Model and thinking level controls for pi.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
- **`/variants`** — Searchable model + thinking level selector. Shows only enabled models from your API. Type to fuzzy-filter, ↑↓ navigate, Enter to confirm.
|
|
8
|
+
- **`/thinking`** — Select thinking level for the current model.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
// settings.json
|
|
14
|
+
{
|
|
15
|
+
"packages": ["npm:pi-model-control"]
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or via pi CLI:
|
|
20
|
+
```
|
|
21
|
+
pi package install pi-model-control
|
|
22
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const DISPLAY_LEVELS = ["default", "minimal", "low", "medium", "high", "max"];
|
|
4
|
+
|
|
5
|
+
function getSupportedLevels(m: any): string[] {
|
|
6
|
+
if (!m.reasoning) return ["default"];
|
|
7
|
+
return DISPLAY_LEVELS.filter((level) => {
|
|
8
|
+
const piLevel = level === "default" ? "off" : level;
|
|
9
|
+
const mapped = m.thinkingLevelMap?.[piLevel];
|
|
10
|
+
if (mapped === null) return false;
|
|
11
|
+
if (level === "max") return mapped !== undefined;
|
|
12
|
+
return true;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function displayToPi(display: string): string {
|
|
17
|
+
return display === "default" ? "off" : display;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function piToDisplay(pi: string): string {
|
|
21
|
+
return pi === "off" ? "default" : pi;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function (pi: ExtensionAPI) {
|
|
25
|
+
pi.registerCommand("thinking", {
|
|
26
|
+
description: "Select thinking level for current model",
|
|
27
|
+
async handler(_args: string, ctx: ExtensionCommandContext) {
|
|
28
|
+
const model = ctx.model;
|
|
29
|
+
if (!model) {
|
|
30
|
+
ctx.ui.notify("No active model", "error");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const levels = getSupportedLevels(model);
|
|
35
|
+
const currentDisplay = piToDisplay(pi.getThinkingLevel());
|
|
36
|
+
const labels = levels.map((l) => l === currentDisplay ? `${l} ✓` : l);
|
|
37
|
+
|
|
38
|
+
const chosen = await ctx.ui.select(`Thinking level — ${model.id}`, labels);
|
|
39
|
+
if (!chosen) return;
|
|
40
|
+
|
|
41
|
+
const displayLevel = chosen.replace(" ✓", "");
|
|
42
|
+
if (!levels.includes(displayLevel)) return;
|
|
43
|
+
|
|
44
|
+
pi.setThinkingLevel(displayToPi(displayLevel) as any);
|
|
45
|
+
ctx.ui.notify(`✓ Thinking: ${displayLevel}`, "info");
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Input, Container, TUI, Text, Spacer, fuzzyFilter, getKeybindings } from "@earendil-works/pi-tui";
|
|
4
|
+
import type { Focusable } from "@earendil-works/pi-tui";
|
|
5
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
|
|
9
|
+
interface VariantEntry {
|
|
10
|
+
provider: string;
|
|
11
|
+
model: string;
|
|
12
|
+
thinking: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DISPLAY_LEVELS = ["default", "minimal", "low", "medium", "high", "max"];
|
|
16
|
+
|
|
17
|
+
function getSupportedLevels(m: any): string[] {
|
|
18
|
+
if (!m.reasoning) return ["default"];
|
|
19
|
+
return DISPLAY_LEVELS.filter((level) => {
|
|
20
|
+
const piLevel = level === "default" ? "off" : level;
|
|
21
|
+
const mapped = m.thinkingLevelMap?.[piLevel];
|
|
22
|
+
if (mapped === null) return false;
|
|
23
|
+
if (level === "max") return mapped !== undefined;
|
|
24
|
+
return true;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function displayToPi(display: string): string {
|
|
29
|
+
return display === "default" ? "off" : display;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadEnabledModels(): Set<string> | null {
|
|
33
|
+
const dir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
34
|
+
const path = join(dir, "settings.json");
|
|
35
|
+
if (existsSync(path)) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = readFileSync(path, "utf-8");
|
|
38
|
+
const data = JSON.parse(raw);
|
|
39
|
+
if (Array.isArray(data.enabledModels)) return new Set(data.enabledModels);
|
|
40
|
+
} catch { /* fall through */ }
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function generateVariants(ctx: ExtensionCommandContext): VariantEntry[] {
|
|
46
|
+
const allModels = ctx.modelRegistry.getAll();
|
|
47
|
+
const enabledSet = loadEnabledModels();
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
const variants: VariantEntry[] = [];
|
|
50
|
+
|
|
51
|
+
for (const m of allModels) {
|
|
52
|
+
const key = `${m.provider}/${m.id}`;
|
|
53
|
+
if (seen.has(key)) continue;
|
|
54
|
+
seen.add(key);
|
|
55
|
+
|
|
56
|
+
if (enabledSet && !enabledSet.has(key)) continue;
|
|
57
|
+
|
|
58
|
+
const levels = getSupportedLevels(m);
|
|
59
|
+
for (const t of levels) {
|
|
60
|
+
variants.push({
|
|
61
|
+
provider: m.provider,
|
|
62
|
+
model: m.id,
|
|
63
|
+
thinking: t,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return variants;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class ModelSelectorComponent extends Container implements Focusable {
|
|
72
|
+
private searchInput: Input;
|
|
73
|
+
private listContainer: Container;
|
|
74
|
+
private allVariants: VariantEntry[];
|
|
75
|
+
private filteredVariants: VariantEntry[];
|
|
76
|
+
private selectedIndex = 0;
|
|
77
|
+
private onSelectCallback: (variant: VariantEntry) => void;
|
|
78
|
+
private onCancelCallback: () => void;
|
|
79
|
+
private tui: TUI;
|
|
80
|
+
private theme: Theme;
|
|
81
|
+
private headerText: Text;
|
|
82
|
+
private hintText: Text;
|
|
83
|
+
|
|
84
|
+
private _focused = false;
|
|
85
|
+
get focused(): boolean {
|
|
86
|
+
return this._focused;
|
|
87
|
+
}
|
|
88
|
+
set focused(value: boolean) {
|
|
89
|
+
this._focused = value;
|
|
90
|
+
this.searchInput.focused = value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
constructor(
|
|
94
|
+
tui: TUI,
|
|
95
|
+
theme: Theme,
|
|
96
|
+
variants: VariantEntry[],
|
|
97
|
+
onSelect: (variant: VariantEntry) => void,
|
|
98
|
+
onCancel: () => void,
|
|
99
|
+
) {
|
|
100
|
+
super();
|
|
101
|
+
this.tui = tui;
|
|
102
|
+
this.theme = theme;
|
|
103
|
+
this.allVariants = variants;
|
|
104
|
+
this.filteredVariants = variants;
|
|
105
|
+
this.onSelectCallback = onSelect;
|
|
106
|
+
this.onCancelCallback = onCancel;
|
|
107
|
+
|
|
108
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
109
|
+
this.addChild(new Spacer(1));
|
|
110
|
+
|
|
111
|
+
this.headerText = new Text("", 1, 0);
|
|
112
|
+
this.addChild(this.headerText);
|
|
113
|
+
this.addChild(new Spacer(1));
|
|
114
|
+
|
|
115
|
+
this.searchInput = new Input();
|
|
116
|
+
this.searchInput.onSubmit = () => {
|
|
117
|
+
const selected = this.filteredVariants[this.selectedIndex];
|
|
118
|
+
if (selected) this.onSelectCallback(selected);
|
|
119
|
+
};
|
|
120
|
+
this.addChild(this.searchInput);
|
|
121
|
+
|
|
122
|
+
this.addChild(new Spacer(1));
|
|
123
|
+
this.listContainer = new Container();
|
|
124
|
+
this.addChild(this.listContainer);
|
|
125
|
+
|
|
126
|
+
this.addChild(new Spacer(1));
|
|
127
|
+
this.hintText = new Text("", 1, 0);
|
|
128
|
+
this.addChild(this.hintText);
|
|
129
|
+
this.addChild(new Spacer(1));
|
|
130
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
131
|
+
|
|
132
|
+
this.updateHeader();
|
|
133
|
+
this.updateHints();
|
|
134
|
+
this.applyFilter("");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private updateHeader(): void {
|
|
138
|
+
this.headerText.setText(
|
|
139
|
+
this.theme.fg("accent", this.theme.bold(`Models (${this.allVariants.length} variants)`)),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private updateHints(): void {
|
|
144
|
+
this.hintText.setText(
|
|
145
|
+
this.theme.fg("dim", "Type to search · ↑↓ select · Enter confirm · Esc cancel"),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private applyFilter(query: string): void {
|
|
150
|
+
if (!query.trim()) {
|
|
151
|
+
this.filteredVariants = this.allVariants;
|
|
152
|
+
} else {
|
|
153
|
+
this.filteredVariants = fuzzyFilter(this.allVariants, query, (v) => `${v.model} ${v.thinking}`);
|
|
154
|
+
}
|
|
155
|
+
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredVariants.length - 1));
|
|
156
|
+
this.updateList();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private updateList(): void {
|
|
160
|
+
this.listContainer.clear();
|
|
161
|
+
|
|
162
|
+
if (this.filteredVariants.length === 0) {
|
|
163
|
+
this.listContainer.addChild(new Text(this.theme.fg("muted", " No matching models"), 0, 0));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const maxVisible = 12;
|
|
168
|
+
const startIndex = Math.max(
|
|
169
|
+
0,
|
|
170
|
+
Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredVariants.length - maxVisible),
|
|
171
|
+
);
|
|
172
|
+
const endIndex = Math.min(startIndex + maxVisible, this.filteredVariants.length);
|
|
173
|
+
|
|
174
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
175
|
+
const v = this.filteredVariants[i];
|
|
176
|
+
if (!v) continue;
|
|
177
|
+
const isSelected = i === this.selectedIndex;
|
|
178
|
+
const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
|
|
179
|
+
const modelColor = isSelected ? "accent" : "text";
|
|
180
|
+
const thinkingColor = isSelected ? "accent" : "muted";
|
|
181
|
+
const line = `${prefix}${this.theme.fg(modelColor, v.model)} ${this.theme.fg(thinkingColor, `(${v.thinking})`)}`;
|
|
182
|
+
this.listContainer.addChild(new Text(line, 0, 0));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (startIndex > 0 || endIndex < this.filteredVariants.length) {
|
|
186
|
+
const scrollInfo = this.theme.fg(
|
|
187
|
+
"dim",
|
|
188
|
+
` (${this.selectedIndex + 1}/${this.filteredVariants.length})`,
|
|
189
|
+
);
|
|
190
|
+
this.listContainer.addChild(new Text(scrollInfo, 0, 0));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
handleInput(keyData: string): void {
|
|
195
|
+
const kb = getKeybindings();
|
|
196
|
+
if (kb.matches(keyData, "tui.select.up")) {
|
|
197
|
+
if (this.filteredVariants.length === 0) return;
|
|
198
|
+
this.selectedIndex = this.selectedIndex === 0 ? this.filteredVariants.length - 1 : this.selectedIndex - 1;
|
|
199
|
+
this.updateList();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (kb.matches(keyData, "tui.select.down")) {
|
|
203
|
+
if (this.filteredVariants.length === 0) return;
|
|
204
|
+
this.selectedIndex = this.selectedIndex === this.filteredVariants.length - 1 ? 0 : this.selectedIndex + 1;
|
|
205
|
+
this.updateList();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (kb.matches(keyData, "tui.select.confirm")) {
|
|
209
|
+
const selected = this.filteredVariants[this.selectedIndex];
|
|
210
|
+
if (selected) this.onSelectCallback(selected);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (kb.matches(keyData, "tui.select.cancel")) {
|
|
214
|
+
this.onCancelCallback();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
this.searchInput.handleInput(keyData);
|
|
218
|
+
this.applyFilter(this.searchInput.getValue());
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
override invalidate(): void {
|
|
222
|
+
super.invalidate();
|
|
223
|
+
this.updateHeader();
|
|
224
|
+
this.updateHints();
|
|
225
|
+
this.updateList();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export default function (pi: ExtensionAPI) {
|
|
230
|
+
pi.registerCommand("variants", {
|
|
231
|
+
description: "Switch model + thinking level (searchable)",
|
|
232
|
+
async handler(_args: string, ctx: ExtensionCommandContext) {
|
|
233
|
+
const variants = generateVariants(ctx);
|
|
234
|
+
|
|
235
|
+
if (variants.length === 0) {
|
|
236
|
+
ctx.ui.notify("No enabled models found", "error");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const result = await ctx.ui.custom<VariantEntry | null>((tui, theme, _kb, done) => {
|
|
241
|
+
let selected: VariantEntry | null = null;
|
|
242
|
+
|
|
243
|
+
const selector = new ModelSelectorComponent(
|
|
244
|
+
tui,
|
|
245
|
+
theme,
|
|
246
|
+
variants,
|
|
247
|
+
(v) => {
|
|
248
|
+
selected = v;
|
|
249
|
+
done();
|
|
250
|
+
},
|
|
251
|
+
() => done(),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
get focused() { return true; },
|
|
256
|
+
set focused(value: boolean) {
|
|
257
|
+
selector.focused = value;
|
|
258
|
+
},
|
|
259
|
+
render(width: number) { return selector.render(width); },
|
|
260
|
+
invalidate() { selector.invalidate(); },
|
|
261
|
+
handleInput(data: string) { selector.handleInput(data); },
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (!result) return;
|
|
266
|
+
|
|
267
|
+
const model = ctx.modelRegistry.find(result.provider, result.model);
|
|
268
|
+
if (!model) {
|
|
269
|
+
ctx.ui.notify(`Model ${result.provider}/${result.model} not found`, "error");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await pi.setModel(model);
|
|
274
|
+
pi.setThinkingLevel(displayToPi(result.thinking) as any);
|
|
275
|
+
ctx.ui.notify(`✓ ${result.model} → ${result.thinking}`, "info");
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-model-control",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Model and thinking level controls for pi — /variants and /thinking commands",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"extensions"
|
|
8
|
+
],
|
|
9
|
+
"keywords": [
|
|
10
|
+
"pi-package",
|
|
11
|
+
"pi",
|
|
12
|
+
"coding-agent",
|
|
13
|
+
"extensions",
|
|
14
|
+
"model",
|
|
15
|
+
"thinking"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18.0.0"
|
|
20
|
+
},
|
|
21
|
+
"pi": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./extensions/variants.ts",
|
|
24
|
+
"./extensions/thinking.ts"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
29
|
+
"@earendil-works/pi-tui": "*"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"sigstore": "^5.0.0"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"libnpmpublish": "^12.0.0",
|
|
36
|
+
"node-fetch": "^3.3.2"
|
|
37
|
+
}
|
|
38
|
+
}
|