pi-model-sort 0.1.0 → 0.1.2
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 +9 -2
- package/model-sort.ts +108 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,7 +36,8 @@ The extension works automatically — there are no commands to learn.
|
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
# Install, then just use pi normally
|
|
39
|
-
/model # Most recently used models
|
|
39
|
+
/model # Most recently used models appear at the top
|
|
40
|
+
Ctrl+P / Ctrl+Shift+P # Cycle through models in last-used order
|
|
40
41
|
pi --list-models # CLI output is also sorted by last usage
|
|
41
42
|
```
|
|
42
43
|
|
|
@@ -104,19 +105,25 @@ Session starts
|
|
|
104
105
|
sortModels — sorts "Scope: all" view
|
|
105
106
|
loadModels — sorts "Scope: scoped" scopedModelItems after load
|
|
106
107
|
→ Monkey-patches ModelRegistry.prototype.getAvailable/getAll
|
|
108
|
+
→ Monkey-patches AgentSession.prototype._cycleScopedModel
|
|
107
109
|
→ Sort order: current model first → most recent → provider/id alphabetical
|
|
108
110
|
→ Patches survive modelRegistry.refresh()
|
|
109
111
|
```
|
|
110
112
|
|
|
111
|
-
**
|
|
113
|
+
**Five patches, full coverage:**
|
|
112
114
|
|
|
113
115
|
| Patch | What it affects |
|
|
114
116
|
|-------|----------------|
|
|
115
117
|
| `ModelSelectorComponent.prototype.sortModels` | `/model` TUI picker — "Scope: all" view |
|
|
116
118
|
| `ModelSelectorComponent.prototype.loadModels` | `/model` TUI picker — "Scope: scoped" view (configured cycling models) |
|
|
119
|
+
| `AgentSession.prototype._cycleScopedModel` | `Ctrl+P` / `Ctrl+Shift+P` cycling order (non-destructive swap, cycling does not update last-used to avoid feedback loop) |
|
|
117
120
|
| `ModelRegistry.prototype.getAvailable()` | `/scoped-models` config selector, model resolution |
|
|
118
121
|
| `ModelRegistry.prototype.getAll()` | `--list-models` CLI output |
|
|
119
122
|
|
|
123
|
+
When no scoped models are configured, Ctrl+P falls through to `_cycleAvailableModel` which calls `getAvailable()` — already sorted by the registry patch.
|
|
124
|
+
|
|
125
|
+
> **Why cycling doesn't update last-used:** Updating timestamps during Ctrl+P cycling creates a feedback loop — each cycle makes the selected model most-recent, re-sorts it to position 0, and `(currentIndex + 1) % len` always lands on the second model. Models would toggle forever between the top 2. Manual model selection (`/model`, session restore) still updates last-used.
|
|
126
|
+
|
|
120
127
|
The SDK doesn't expose a sort order for model lists. Monkey-patching the component and registry methods is the only way to control ordering without rebuilding the entire picker UI.
|
|
121
128
|
|
|
122
129
|
The patches survive `modelRegistry.refresh()` because they wrap the original methods. On reload, the extension detects the prototypes are already patched and just updates the last-used data source.
|
package/model-sort.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-model-sort — sort models in pi by last usage (descending).
|
|
3
3
|
*
|
|
4
|
-
* Strategy: monkey-patches
|
|
5
|
-
* loadModels
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Strategy: monkey-patches three areas:
|
|
5
|
+
* ModelSelectorComponent.prototype.sortModels and loadModels — sorts both
|
|
6
|
+
* "Scope: all" and "Scope: scoped" views in the /model TUI picker.
|
|
7
|
+
* ModelRegistry.prototype.getAvailable/getAll — sorts --list-models CLI
|
|
8
|
+
* and the /scoped-models config selector.
|
|
9
|
+
* AgentSession.prototype._cycleScopedModel — sorts the Ctrl+P / Ctrl+Shift+P
|
|
10
|
+
* cycling order (non-destructively — the configured order is preserved).
|
|
9
11
|
*
|
|
10
12
|
* Usage tracking is automatic — every model selection (manual, Ctrl+P cycle,
|
|
11
13
|
* or session restore) updates the last-used timestamp. Data persists to
|
|
@@ -16,7 +18,7 @@
|
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
-
import { ModelRegistry, ModelSelectorComponent } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { AgentSession, ModelRegistry, ModelSelectorComponent } from "@earendil-works/pi-coding-agent";
|
|
20
22
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
21
23
|
import { homedir } from "node:os";
|
|
22
24
|
import { join } from "node:path";
|
|
@@ -134,6 +136,47 @@ function unpatchLoadModels(): void {
|
|
|
134
136
|
origLoadModels = null;
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
// ModelSelectorComponent filterModels patch — re-applies last-used sort after
|
|
140
|
+
// fuzzyFilter re-orders results by match quality. Without this, typing in the
|
|
141
|
+
// /model picker search box discards the last-used order.
|
|
142
|
+
|
|
143
|
+
let origFilterModels: ((query: string) => void) | null = null;
|
|
144
|
+
|
|
145
|
+
function patchFilterModels(getLastUsed: () => Record<string, number>): void {
|
|
146
|
+
if (origFilterModels !== null) return;
|
|
147
|
+
|
|
148
|
+
const proto = ModelSelectorComponent.prototype as unknown as Record<string, unknown>;
|
|
149
|
+
origFilterModels = proto.filterModels as (query: string) => void;
|
|
150
|
+
|
|
151
|
+
proto.filterModels = function (this: Record<string, unknown>, query: string) {
|
|
152
|
+
origFilterModels!.call(this, query);
|
|
153
|
+
|
|
154
|
+
const filtered = this.filteredModels as Array<{ provider: string; id: string; model: unknown }> | undefined;
|
|
155
|
+
if (!filtered || filtered.length <= 1 || !query) return;
|
|
156
|
+
|
|
157
|
+
const lastUsed = getLastUsed();
|
|
158
|
+
this.filteredModels = sortByLastUsed(filtered, lastUsed, buildCurrentModelKey(this));
|
|
159
|
+
|
|
160
|
+
// Re-sync selectedIndex — fuzzyFilter may have moved the current model.
|
|
161
|
+
const currentKey = buildCurrentModelKey(this);
|
|
162
|
+
if (currentKey) {
|
|
163
|
+
const newFiltered = this.filteredModels as Array<{ provider: string; id: string }>;
|
|
164
|
+
const newIndex = newFiltered.findIndex(
|
|
165
|
+
(item) => buildModelKey(item.provider, item.id) === currentKey,
|
|
166
|
+
);
|
|
167
|
+
if (newIndex >= 0) {
|
|
168
|
+
this.selectedIndex = newIndex;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function unpatchFilterModels(): void {
|
|
175
|
+
if (origFilterModels === null) return;
|
|
176
|
+
(ModelSelectorComponent.prototype as unknown as Record<string, unknown>).filterModels = origFilterModels;
|
|
177
|
+
origFilterModels = null;
|
|
178
|
+
}
|
|
179
|
+
|
|
137
180
|
// ModelRegistry getAvailable / getAll patch
|
|
138
181
|
|
|
139
182
|
const REGISTRY_PATCH_KEY = "__model_sort_registry_patched";
|
|
@@ -188,6 +231,55 @@ function unpatchRegistry(registry: PatchedRegistry): void {
|
|
|
188
231
|
delete raw.__model_sort_orig_getAll;
|
|
189
232
|
}
|
|
190
233
|
|
|
234
|
+
// AgentSession _cycleScopedModel patch — sorts the scoped models list
|
|
235
|
+
// before cycling so Ctrl+P / Ctrl+Shift+P follows last-used order instead
|
|
236
|
+
// of the configured order. Non-destructive: the session's stored order is
|
|
237
|
+
// temporarily swapped and restored after the cycle lookup.
|
|
238
|
+
|
|
239
|
+
type ScopedModelEntry = { model: { provider: string; id: string }; thinkingLevel?: string };
|
|
240
|
+
|
|
241
|
+
let origCycleScopedModel: ((direction: string) => Promise<unknown>) | null = null;
|
|
242
|
+
|
|
243
|
+
function patchCycleScopedModel(getLastUsed: () => Record<string, number>): void {
|
|
244
|
+
if (origCycleScopedModel !== null) return;
|
|
245
|
+
|
|
246
|
+
const proto = AgentSession.prototype as unknown as Record<string, unknown>;
|
|
247
|
+
origCycleScopedModel = proto._cycleScopedModel as (direction: string) => Promise<unknown>;
|
|
248
|
+
|
|
249
|
+
proto._cycleScopedModel = async function (this: Record<string, unknown>, direction: string) {
|
|
250
|
+
const lastUsed = getLastUsed();
|
|
251
|
+
const origScoped = this._scopedModels as ScopedModelEntry[] | undefined;
|
|
252
|
+
|
|
253
|
+
if (!origScoped || origScoped.length <= 1) {
|
|
254
|
+
return origCycleScopedModel!.call(this, direction);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Sort by last-used without mutating the session's stored order.
|
|
258
|
+
const sorted = [...origScoped].sort((a, b) => {
|
|
259
|
+
const aKey = buildModelKey(a.model.provider, a.model.id);
|
|
260
|
+
const bKey = buildModelKey(b.model.provider, b.model.id);
|
|
261
|
+
const aLast = lastUsed[aKey] ?? 0;
|
|
262
|
+
const bLast = lastUsed[bKey] ?? 0;
|
|
263
|
+
if (aLast !== bLast) return bLast - aLast;
|
|
264
|
+
return a.model.provider.localeCompare(b.model.provider) || a.model.id.localeCompare(b.model.id);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Temporarily swap for the cycle lookup, restore afterward.
|
|
268
|
+
this._scopedModels = sorted;
|
|
269
|
+
try {
|
|
270
|
+
return await origCycleScopedModel!.call(this, direction);
|
|
271
|
+
} finally {
|
|
272
|
+
this._scopedModels = origScoped;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function unpatchCycleScopedModel(): void {
|
|
278
|
+
if (origCycleScopedModel === null) return;
|
|
279
|
+
(AgentSession.prototype as unknown as Record<string, unknown>)._cycleScopedModel = origCycleScopedModel;
|
|
280
|
+
origCycleScopedModel = null;
|
|
281
|
+
}
|
|
282
|
+
|
|
191
283
|
// Extension
|
|
192
284
|
|
|
193
285
|
export default function (pi: ExtensionAPI) {
|
|
@@ -200,6 +292,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
200
292
|
patchRegistry(ctx.modelRegistry as unknown as PatchedRegistry, () => lastUsed);
|
|
201
293
|
patchSortModels(() => lastUsed);
|
|
202
294
|
patchLoadModels(() => lastUsed);
|
|
295
|
+
patchFilterModels(() => lastUsed);
|
|
296
|
+
patchCycleScopedModel(() => lastUsed);
|
|
203
297
|
|
|
204
298
|
if (ctx.hasUI) {
|
|
205
299
|
const count = Object.keys(lastUsed).length;
|
|
@@ -212,8 +306,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
212
306
|
}
|
|
213
307
|
});
|
|
214
308
|
|
|
215
|
-
// Track
|
|
309
|
+
// Track model selections (manual, session restore).
|
|
310
|
+
// Skip "cycle" events — updating lastUsed during Ctrl+P cycling creates
|
|
311
|
+
// a feedback loop: each cycle step makes the selected model most-recent,
|
|
312
|
+
// re-sorts it to position 0, then (currentIndex + 1) % len always hits
|
|
313
|
+
// position 1 — toggling forever between the top 2.
|
|
216
314
|
pi.on("model_select", async (event, _ctx) => {
|
|
315
|
+
if (event.source === "cycle") return;
|
|
217
316
|
const key = buildModelKey(event.model.provider, event.model.id);
|
|
218
317
|
lastUsed[key] = Date.now();
|
|
219
318
|
writeConfig({ lastUsed });
|
|
@@ -223,5 +322,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
223
322
|
pi.on("session_shutdown", () => {
|
|
224
323
|
unpatchSortModels();
|
|
225
324
|
unpatchLoadModels();
|
|
325
|
+
unpatchFilterModels();
|
|
326
|
+
unpatchCycleScopedModel();
|
|
226
327
|
});
|
|
227
328
|
}
|