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.
Files changed (3) hide show
  1. package/README.md +9 -2
  2. package/model-sort.ts +108 -7
  3. 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 now appear at the top
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
- **Four patches, full coverage:**
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 ModelSelectorComponent.prototype.sortModels and
5
- * loadModels so the /model picker sorts by recency instead of alphabetically
6
- * by provider — including the "Scope: scoped" view for Ctrl+P cycling.
7
- * Also patches ModelRegistry.getAvailable() and getAll() so --list-models
8
- * and the scoped-models config selector benefit from the same ordering.
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 every model selection (manual, Ctrl+P cycle, session restore)
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-model-sort",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Sort models in pi's /model selector by last usage — most recently used models appear first",
5
5
  "type": "module",
6
6
  "author": "Tom X Nguyen",