pi-model-sort 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 ADDED
@@ -0,0 +1,159 @@
1
+ <div align="center">
2
+
3
+ # 🔄 pi-model-sort
4
+
5
+ **Sort models by last usage in [pi](https://github.com/earendil-works/pi-coding-agent)**
6
+
7
+ _Your most-used models appear first — no more scrolling past providers you never touch._
8
+
9
+ [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi-coding-agent)
10
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ ## The Problem
17
+
18
+ Pi's `/model` selector sorts models alphabetically by provider. If you have Anthropic + OpenAI + Google + Ollama all configured, your most-used model might be buried behind twenty other models. Every time you open the picker, you scroll past providers you haven't touched in weeks. There's no built-in way to say *"show me what I actually use."*
19
+
20
+ ## The Solution
21
+
22
+ `pi-model-sort` tracks every model selection and reorders the `/model` picker so your most recently used models appear at the top.
23
+
24
+ - **Automatic tracking** — every `/model` switch, `Ctrl+P` cycle, and session restore is recorded with a Unix timestamp
25
+ - **Sort order** — current model first → most recently used descending → provider/id alphabetical fallback
26
+ - **Persistent** — usage data lives in `~/.pi/agent/extensions/pi-model-sort.json`, survives restarts
27
+ - **No config needed** — install and forget; the extension starts tracking on first use
28
+ - **Zero setup** — with no recorded usage, models fall back to the default alphabetical order
29
+ - **Everywhere** — the sort applies to `/model` (`Ctrl+L`), both "Scope: all" and "Scope: scoped" views, `--list-models` CLI, and the `/scoped-models` config selector
30
+
31
+ No `settings.json` modifications. No manual maintenance. No database.
32
+
33
+ ## Usage
34
+
35
+ The extension works automatically — there are no commands to learn.
36
+
37
+ ```bash
38
+ # Install, then just use pi normally
39
+ /model # Most recently used models now appear at the top
40
+ pi --list-models # CLI output is also sorted by last usage
41
+ ```
42
+
43
+ Open `/model` and press `Tab` to switch between "Scope: all" and "Scope: scoped" — both views are sorted by recency.
44
+
45
+ ### Config File
46
+
47
+ The extension creates `~/.pi/agent/extensions/pi-model-sort.json` automatically on first model switch:
48
+
49
+ ```json
50
+ {
51
+ "lastUsed": {
52
+ "anthropic/claude-sonnet-4-20250514": 1717000000000,
53
+ "openai/gpt-4o": 1716995000000,
54
+ "google/gemini-2.5-pro": 1716000000000
55
+ }
56
+ }
57
+ ```
58
+
59
+ No manual editing needed. To clear usage history, delete the file and `/reload`.
60
+
61
+ ## Install
62
+
63
+ **With `pi install`** (recommended):
64
+
65
+ ```bash
66
+ pi install https://github.com/monotykamary/pi-model-sort
67
+ ```
68
+
69
+ **With npm**:
70
+
71
+ ```bash
72
+ npm install pi-model-sort
73
+ ```
74
+
75
+ Or in `~/.pi/agent/settings.json`:
76
+
77
+ ```json
78
+ {
79
+ "packages": [
80
+ "git:github.com/monotykamary/pi-model-sort"
81
+ ]
82
+ }
83
+ ```
84
+
85
+ Then `/reload` or restart pi.
86
+
87
+ For quick one-off tests:
88
+
89
+ ```bash
90
+ pi -e ./model-sort.ts
91
+ ```
92
+
93
+ ## How It Works
94
+
95
+ ```
96
+ model_select event fires
97
+ → Extension records timestamp
98
+ → Writes to pi-model-sort.json
99
+ → Next /model opens with updated sort
100
+
101
+ Session starts
102
+ → Extension reads pi-model-sort.json
103
+ → Monkey-patches ModelSelectorComponent.prototype:
104
+ sortModels — sorts "Scope: all" view
105
+ loadModels — sorts "Scope: scoped" scopedModelItems after load
106
+ → Monkey-patches ModelRegistry.prototype.getAvailable/getAll
107
+ → Sort order: current model first → most recent → provider/id alphabetical
108
+ → Patches survive modelRegistry.refresh()
109
+ ```
110
+
111
+ **Four patches, full coverage:**
112
+
113
+ | Patch | What it affects |
114
+ |-------|----------------|
115
+ | `ModelSelectorComponent.prototype.sortModels` | `/model` TUI picker — "Scope: all" view |
116
+ | `ModelSelectorComponent.prototype.loadModels` | `/model` TUI picker — "Scope: scoped" view (configured cycling models) |
117
+ | `ModelRegistry.prototype.getAvailable()` | `/scoped-models` config selector, model resolution |
118
+ | `ModelRegistry.prototype.getAll()` | `--list-models` CLI output |
119
+
120
+ 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
+
122
+ 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.
123
+
124
+ ## Comparison with Alternatives
125
+
126
+ | Approach | Pros | Cons |
127
+ |----------|------|------|
128
+ | **pi-model-sort** (this) | Automatic, zero-config, persistent, applies everywhere | Monkey-patches internal prototypes |
129
+ | `enabledModels` in `settings.json` (manual) | Built-in, no extension needed | Allowlist — must list models manually; doesn't sort, just scopes |
130
+ | Custom `/model` replacement extension | Full control over UI | Rebuilds the entire picker component from scratch (~400 lines of TUI code) |
131
+ | Manually ordering `models.json` | Controls `--list-models` output | Static, doesn't react to actual usage; doesn't affect `/model` picker |
132
+
133
+ ## Development
134
+
135
+ ```bash
136
+ npm install
137
+ npm test # Vitest unit tests
138
+ npm run typecheck # TypeScript validation
139
+ npm run lint:dead # Dead code detection (knip)
140
+ ```
141
+
142
+ ### Structure
143
+
144
+ ```
145
+ .
146
+ ├── model-sort.ts # Main extension
147
+ ├── src/
148
+ │ └── index.ts # Sort logic, types, and utilities
149
+ ├── __tests__/
150
+ │ └── sort.test.ts # Unit tests for sortByLastUsed
151
+ ├── package.json
152
+ ├── tsconfig.json
153
+ ├── vitest.config.ts
154
+ └── knip.json
155
+ ```
156
+
157
+ ## License
158
+
159
+ MIT
package/model-sort.ts ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * pi-model-sort — sort models in pi by last usage (descending).
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.
9
+ *
10
+ * Usage tracking is automatic — every model selection (manual, Ctrl+P cycle,
11
+ * or session restore) updates the last-used timestamp. Data persists to
12
+ * ~/.pi/agent/extensions/pi-model-sort.json.
13
+ *
14
+ * With no recorded usage, the sort degrades gracefully to the default
15
+ * provider/model-id alphabetical order.
16
+ */
17
+
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { ModelRegistry, ModelSelectorComponent } from "@earendil-works/pi-coding-agent";
20
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import {
24
+ buildModelKey,
25
+ CONFIG_FILENAME,
26
+ type ModelSortConfig,
27
+ sortByLastUsed,
28
+ } from "./src/index.js";
29
+
30
+ const HOME = homedir();
31
+ const CONFIG_PATH = join(HOME, ".pi", "agent", "extensions", CONFIG_FILENAME);
32
+
33
+ // Config I/O
34
+
35
+ function readConfig(): ModelSortConfig {
36
+ if (!existsSync(CONFIG_PATH)) {
37
+ return { lastUsed: {} };
38
+ }
39
+ try {
40
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
41
+ const parsed = JSON.parse(raw) as ModelSortConfig;
42
+ return { lastUsed: parsed.lastUsed ?? {} };
43
+ } catch {
44
+ return { lastUsed: {} };
45
+ }
46
+ }
47
+
48
+ function writeConfig(config: ModelSortConfig): void {
49
+ const dir = join(HOME, ".pi", "agent", "extensions");
50
+ if (!existsSync(dir)) {
51
+ mkdirSync(dir, { recursive: true });
52
+ }
53
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
54
+ }
55
+
56
+ // ModelSelectorComponent sortModels patch
57
+
58
+ let origSortModels: ((models: Array<{ provider: string; id: string; model: unknown }>) => Array<{ provider: string; id: string; model: unknown }>) | null = null;
59
+
60
+ function buildCurrentModelKey(instance: Record<string, unknown>): string | null {
61
+ const cm = instance.currentModel as { provider?: string; id?: string } | undefined;
62
+ if (cm?.provider && cm?.id) {
63
+ return buildModelKey(cm.provider, cm.id);
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function patchSortModels(getLastUsed: () => Record<string, number>): void {
69
+ if (origSortModels !== null) return;
70
+
71
+ const proto = ModelSelectorComponent.prototype as unknown as Record<string, unknown>;
72
+ origSortModels = proto.sortModels as typeof origSortModels;
73
+
74
+ proto.sortModels = function (
75
+ this: Record<string, unknown>,
76
+ models: Array<{ provider: string; id: string; model: unknown }>,
77
+ ) {
78
+ const lastUsed = getLastUsed();
79
+ return sortByLastUsed(models, lastUsed, buildCurrentModelKey(this));
80
+ };
81
+ }
82
+
83
+ function unpatchSortModels(): void {
84
+ if (origSortModels === null) return;
85
+ (ModelSelectorComponent.prototype as unknown as Record<string, unknown>).sortModels = origSortModels;
86
+ origSortModels = null;
87
+ }
88
+
89
+ // ModelSelectorComponent loadModels patch — sorts scopedModelItems for the
90
+ // "Scope: scoped" toggle in the /model picker.
91
+
92
+ let origLoadModels: (() => Promise<void>) | null = null;
93
+
94
+ function patchLoadModels(getLastUsed: () => Record<string, number>): void {
95
+ if (origLoadModels !== null) return;
96
+
97
+ const proto = ModelSelectorComponent.prototype as unknown as Record<string, unknown>;
98
+ origLoadModels = proto.loadModels as () => Promise<void>;
99
+
100
+ proto.loadModels = async function (this: Record<string, unknown>) {
101
+ await origLoadModels!.call(this);
102
+
103
+ const scopedItems = this.scopedModelItems as Array<{ provider: string; id: string; model: unknown }> | undefined;
104
+ if (!scopedItems || scopedItems.length === 0) return;
105
+
106
+ const lastUsed = getLastUsed();
107
+ this.scopedModelItems = sortByLastUsed(scopedItems, lastUsed, buildCurrentModelKey(this));
108
+
109
+ if (this.scope === "scoped") {
110
+ this.activeModels = this.scopedModelItems;
111
+ // Sync filteredModels — the original loadModels set it to the
112
+ // unsorted scopedModelItems before our patch had a chance to sort.
113
+ this.filteredModels = this.scopedModelItems;
114
+
115
+ // Recalculate selectedIndex — the original loadModels computed it
116
+ // from the unsorted array, so the cursor is at the old position.
117
+ const currentKey = buildCurrentModelKey(this);
118
+ if (currentKey) {
119
+ const filtered = this.filteredModels as Array<{ provider: string; id: string }>;
120
+ const newIndex = filtered.findIndex(
121
+ (item) => buildModelKey(item.provider, item.id) === currentKey,
122
+ );
123
+ if (newIndex >= 0) {
124
+ this.selectedIndex = newIndex;
125
+ }
126
+ }
127
+ }
128
+ };
129
+ }
130
+
131
+ function unpatchLoadModels(): void {
132
+ if (origLoadModels === null) return;
133
+ (ModelSelectorComponent.prototype as unknown as Record<string, unknown>).loadModels = origLoadModels;
134
+ origLoadModels = null;
135
+ }
136
+
137
+ // ModelRegistry getAvailable / getAll patch
138
+
139
+ const REGISTRY_PATCH_KEY = "__model_sort_registry_patched";
140
+
141
+ interface PatchedRegistry {
142
+ [REGISTRY_PATCH_KEY]: boolean;
143
+ getAvailable(): unknown[];
144
+ getAll(): unknown[];
145
+ __model_sort_get_last_used: () => Record<string, number>;
146
+ __model_sort_orig_getAvailable: () => unknown[];
147
+ __model_sort_orig_getAll: () => unknown[];
148
+ }
149
+
150
+ function patchRegistry(
151
+ registry: PatchedRegistry,
152
+ getLastUsed: () => Record<string, number>,
153
+ ): void {
154
+ if (registry[REGISTRY_PATCH_KEY]) {
155
+ registry.__model_sort_get_last_used = getLastUsed;
156
+ return;
157
+ }
158
+
159
+ registry[REGISTRY_PATCH_KEY] = true;
160
+ registry.__model_sort_get_last_used = getLastUsed;
161
+
162
+ registry.__model_sort_orig_getAvailable = registry.getAvailable.bind(registry);
163
+ registry.__model_sort_orig_getAll = registry.getAll.bind(registry);
164
+
165
+ registry.getAvailable = function (this: PatchedRegistry) {
166
+ const lastUsed = this.__model_sort_get_last_used();
167
+ const all = this.__model_sort_orig_getAvailable() as Array<{ provider: string; id: string }>;
168
+ return sortByLastUsed(all, lastUsed, null);
169
+ };
170
+
171
+ registry.getAll = function (this: PatchedRegistry) {
172
+ const lastUsed = this.__model_sort_get_last_used();
173
+ const all = this.__model_sort_orig_getAll() as Array<{ provider: string; id: string }>;
174
+ return sortByLastUsed(all, lastUsed, null);
175
+ };
176
+ }
177
+
178
+ function unpatchRegistry(registry: PatchedRegistry): void {
179
+ if (!registry[REGISTRY_PATCH_KEY]) return;
180
+
181
+ registry.getAvailable = registry.__model_sort_orig_getAvailable;
182
+ registry.getAll = registry.__model_sort_orig_getAll;
183
+
184
+ const raw = registry as unknown as Record<string, unknown>;
185
+ delete raw[REGISTRY_PATCH_KEY];
186
+ delete raw.__model_sort_get_last_used;
187
+ delete raw.__model_sort_orig_getAvailable;
188
+ delete raw.__model_sort_orig_getAll;
189
+ }
190
+
191
+ // Extension
192
+
193
+ export default function (pi: ExtensionAPI) {
194
+ let lastUsed: Record<string, number> = {};
195
+
196
+ pi.on("session_start", async (_event, ctx) => {
197
+ const config = readConfig();
198
+ lastUsed = config.lastUsed;
199
+
200
+ patchRegistry(ctx.modelRegistry as unknown as PatchedRegistry, () => lastUsed);
201
+ patchSortModels(() => lastUsed);
202
+ patchLoadModels(() => lastUsed);
203
+
204
+ if (ctx.hasUI) {
205
+ const count = Object.keys(lastUsed).length;
206
+ ctx.ui.notify(
207
+ count > 0
208
+ ? `pi-model-sort: ${count} model(s) tracked — sorting by last usage`
209
+ : "pi-model-sort: tracking started — models will sort by recency after first use",
210
+ "info",
211
+ );
212
+ }
213
+ });
214
+
215
+ // Track every model selection (manual, Ctrl+P cycle, session restore)
216
+ pi.on("model_select", async (event, _ctx) => {
217
+ const key = buildModelKey(event.model.provider, event.model.id);
218
+ lastUsed[key] = Date.now();
219
+ writeConfig({ lastUsed });
220
+ });
221
+
222
+ // Cleanup on shutdown / reload
223
+ pi.on("session_shutdown", () => {
224
+ unpatchSortModels();
225
+ unpatchLoadModels();
226
+ });
227
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "pi-model-sort",
3
+ "version": "0.1.0",
4
+ "description": "Sort models in pi's /model selector by last usage — most recently used models appear first",
5
+ "type": "module",
6
+ "author": "Tom X Nguyen",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/monotykamary/pi-model-sort.git"
11
+ },
12
+ "homepage": "https://github.com/monotykamary/pi-model-sort#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/monotykamary/pi-model-sort/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi",
19
+ "pi-coding-agent",
20
+ "extension",
21
+ "model",
22
+ "sort",
23
+ "recent",
24
+ "last-used",
25
+ "model-selector"
26
+ ],
27
+ "files": [
28
+ "*.ts",
29
+ "src/",
30
+ "README.md"
31
+ ],
32
+ "scripts": {
33
+ "test": "vitest run",
34
+ "test:watch": "vitest",
35
+ "test:coverage": "vitest run --coverage",
36
+ "typecheck": "tsc --noEmit",
37
+ "lint:dead": "knip --no-gitignore"
38
+ },
39
+ "devDependencies": {
40
+ "@earendil-works/pi-coding-agent": "0.75.4",
41
+ "@types/node": "25.9.1",
42
+ "@vitest/coverage-v8": "4.1.7",
43
+ "knip": "6.14.1",
44
+ "typescript": "6.0.3",
45
+ "vitest": "4.1.7"
46
+ },
47
+ "pi": {
48
+ "extensions": [
49
+ "./model-sort.ts"
50
+ ]
51
+ }
52
+ }
package/src/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Shared constants, types, and utilities for pi-model-sort.
3
+ */
4
+
5
+ /** Default config file name (placed in ~/.pi/agent/extensions/). */
6
+ export const CONFIG_FILENAME = "pi-model-sort.json";
7
+
8
+ export interface ModelSortConfig {
9
+ /** Map of "provider/modelId" → last-used Unix timestamp (ms). */
10
+ lastUsed: Record<string, number>;
11
+ }
12
+
13
+ /** Build a stable model key from provider and model id. */
14
+ export function buildModelKey(provider: string, modelId: string): string {
15
+ return `${provider}/${modelId}`;
16
+ }
17
+
18
+ /**
19
+ * Sort an array of models (or model-like objects) by last-usage recency.
20
+ *
21
+ * Sort order:
22
+ * 1. Current model first (if currentModelKey is provided)
23
+ * 2. Most recently used (highest timestamp) first
24
+ * 3. Provider name alphabetically
25
+ * 4. Model id alphabetically
26
+ *
27
+ * Models with no recorded usage get timestamp 0 (sorted last).
28
+ */
29
+ export function sortByLastUsed<T extends { provider: string; id: string }>(
30
+ items: T[],
31
+ lastUsed: Record<string, number>,
32
+ currentModelKey: string | null,
33
+ ): T[] {
34
+ const sorted = [...items];
35
+ sorted.sort((a, b) => {
36
+ const aKey = buildModelKey(a.provider, a.id);
37
+ const bKey = buildModelKey(b.provider, b.id);
38
+
39
+ if (currentModelKey !== null) {
40
+ const aIsCurrent = aKey === currentModelKey;
41
+ const bIsCurrent = bKey === currentModelKey;
42
+ if (aIsCurrent && !bIsCurrent) return -1;
43
+ if (!aIsCurrent && bIsCurrent) return 1;
44
+ }
45
+
46
+ const aLast = lastUsed[aKey] ?? 0;
47
+ const bLast = lastUsed[bKey] ?? 0;
48
+ if (aLast !== bLast) return bLast - aLast;
49
+
50
+ return a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id);
51
+ });
52
+ return sorted;
53
+ }
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: "node",
7
+ include: ["__tests__/**/*.test.ts"],
8
+ exclude: ["node_modules", "dist", ".idea", ".git", ".cache"],
9
+ coverage: {
10
+ provider: "v8",
11
+ reporter: ["text", "json", "html"],
12
+ exclude: ["node_modules/", "**/*.d.ts", "**/*.test.ts"],
13
+ },
14
+ },
15
+ });