pi-model-sort 0.1.0 → 0.1.1

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 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";
@@ -188,6 +190,55 @@ function unpatchRegistry(registry: PatchedRegistry): void {
188
190
  delete raw.__model_sort_orig_getAll;
189
191
  }
190
192
 
193
+ // AgentSession _cycleScopedModel patch — sorts the scoped models list
194
+ // before cycling so Ctrl+P / Ctrl+Shift+P follows last-used order instead
195
+ // of the configured order. Non-destructive: the session's stored order is
196
+ // temporarily swapped and restored after the cycle lookup.
197
+
198
+ type ScopedModelEntry = { model: { provider: string; id: string }; thinkingLevel?: string };
199
+
200
+ let origCycleScopedModel: ((direction: string) => Promise<unknown>) | null = null;
201
+
202
+ function patchCycleScopedModel(getLastUsed: () => Record<string, number>): void {
203
+ if (origCycleScopedModel !== null) return;
204
+
205
+ const proto = AgentSession.prototype as unknown as Record<string, unknown>;
206
+ origCycleScopedModel = proto._cycleScopedModel as (direction: string) => Promise<unknown>;
207
+
208
+ proto._cycleScopedModel = async function (this: Record<string, unknown>, direction: string) {
209
+ const lastUsed = getLastUsed();
210
+ const origScoped = this._scopedModels as ScopedModelEntry[] | undefined;
211
+
212
+ if (!origScoped || origScoped.length <= 1) {
213
+ return origCycleScopedModel!.call(this, direction);
214
+ }
215
+
216
+ // Sort by last-used without mutating the session's stored order.
217
+ const sorted = [...origScoped].sort((a, b) => {
218
+ const aKey = buildModelKey(a.model.provider, a.model.id);
219
+ const bKey = buildModelKey(b.model.provider, b.model.id);
220
+ const aLast = lastUsed[aKey] ?? 0;
221
+ const bLast = lastUsed[bKey] ?? 0;
222
+ if (aLast !== bLast) return bLast - aLast;
223
+ return a.model.provider.localeCompare(b.model.provider) || a.model.id.localeCompare(b.model.id);
224
+ });
225
+
226
+ // Temporarily swap for the cycle lookup, restore afterward.
227
+ this._scopedModels = sorted;
228
+ try {
229
+ return await origCycleScopedModel!.call(this, direction);
230
+ } finally {
231
+ this._scopedModels = origScoped;
232
+ }
233
+ };
234
+ }
235
+
236
+ function unpatchCycleScopedModel(): void {
237
+ if (origCycleScopedModel === null) return;
238
+ (AgentSession.prototype as unknown as Record<string, unknown>)._cycleScopedModel = origCycleScopedModel;
239
+ origCycleScopedModel = null;
240
+ }
241
+
191
242
  // Extension
192
243
 
193
244
  export default function (pi: ExtensionAPI) {
@@ -200,6 +251,7 @@ export default function (pi: ExtensionAPI) {
200
251
  patchRegistry(ctx.modelRegistry as unknown as PatchedRegistry, () => lastUsed);
201
252
  patchSortModels(() => lastUsed);
202
253
  patchLoadModels(() => lastUsed);
254
+ patchCycleScopedModel(() => lastUsed);
203
255
 
204
256
  if (ctx.hasUI) {
205
257
  const count = Object.keys(lastUsed).length;
@@ -212,8 +264,13 @@ export default function (pi: ExtensionAPI) {
212
264
  }
213
265
  });
214
266
 
215
- // Track every model selection (manual, Ctrl+P cycle, session restore)
267
+ // Track model selections (manual, session restore).
268
+ // Skip "cycle" events — updating lastUsed during Ctrl+P cycling creates
269
+ // a feedback loop: each cycle step makes the selected model most-recent,
270
+ // re-sorts it to position 0, then (currentIndex + 1) % len always hits
271
+ // position 1 — toggling forever between the top 2.
216
272
  pi.on("model_select", async (event, _ctx) => {
273
+ if (event.source === "cycle") return;
217
274
  const key = buildModelKey(event.model.provider, event.model.id);
218
275
  lastUsed[key] = Date.now();
219
276
  writeConfig({ lastUsed });
@@ -223,5 +280,6 @@ export default function (pi: ExtensionAPI) {
223
280
  pi.on("session_shutdown", () => {
224
281
  unpatchSortModels();
225
282
  unpatchLoadModels();
283
+ unpatchCycleScopedModel();
226
284
  });
227
285
  }
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.1",
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",