@xynogen/pix-models 0.1.7 → 0.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-models",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Pi extension — enhanced /models picker with BenchLM ranks",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -38,7 +38,8 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@xynogen/pix-data": "*"
41
+ "@xynogen/pix-data": "*",
42
+ "@xynogen/pix-pretty": "*"
42
43
  },
43
44
  "peerDependencies": {
44
45
  "@earendil-works/pi-coding-agent": "*",
@@ -92,4 +92,36 @@ describe("sortModels", () => {
92
92
  sortModels(models);
93
93
  expect(models).toEqual(original);
94
94
  });
95
+
96
+ it("puts null score models after all scored models regardless of source order", () => {
97
+ const shuffled = [
98
+ { provider: "a", id: "m1", name: "Zeta", score: null },
99
+ { provider: "a", id: "m2", name: "Beta", score: 80 },
100
+ { provider: "a", id: "m3", name: "Alpha", score: 95 },
101
+ { provider: "a", id: "m4", name: "Gamma", score: null },
102
+ { provider: "a", id: "m5", name: "Delta", score: 60 },
103
+ ];
104
+ const sorted = sortModels(shuffled);
105
+ const lastTwo = sorted.slice(-2).map((m) => m.name);
106
+ expect(lastTwo).toEqual(["Gamma", "Zeta"]); // nulls last, stable within
107
+ });
108
+
109
+ it("sinks tier 2 (off-catalog) below tier 1 (benched-but-unscored)", () => {
110
+ // Mirrors the openrouter/owl-alpha bug: a model with no bench entry at
111
+ // all must not interleave with benched models that happen to have a
112
+ // null score.
113
+ const mixed = [
114
+ { provider: "a", id: "m1", name: "Alpha", score: 95, tier: 0 },
115
+ { provider: "a", id: "m2", name: "Beta", score: null, tier: 1 },
116
+ { provider: "a", id: "m3", name: "Gamma", score: undefined, tier: 2 },
117
+ { provider: "a", id: "m4", name: "Delta", score: 60, tier: 0 },
118
+ ];
119
+ const sorted = sortModels(mixed);
120
+ expect(sorted.map((m) => m.name)).toEqual([
121
+ "Alpha",
122
+ "Delta",
123
+ "Beta",
124
+ "Gamma",
125
+ ]);
126
+ });
95
127
  });
package/src/models.ts CHANGED
@@ -12,15 +12,12 @@ import type {
12
12
  ExtensionAPI,
13
13
  ExtensionContext,
14
14
  } from "@earendil-works/pi-coding-agent";
15
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
16
15
  import {
17
- Container,
18
16
  fuzzyFilter,
19
17
  Input,
20
18
  matchesKey,
21
19
  type SelectItem,
22
20
  SelectList,
23
- Text,
24
21
  visibleWidth,
25
22
  } from "@earendil-works/pi-tui";
26
23
  import {
@@ -28,6 +25,7 @@ import {
28
25
  lookupBenchmark,
29
26
  lookupModelsDev,
30
27
  } from "@xynogen/pix-data";
28
+ import { frameLines, modalWidth } from "@xynogen/pix-pretty/modal-frame";
31
29
  import { patchOutBuiltinModelCommand } from "./patch-builtin";
32
30
 
33
31
  // ─── Pure logic (exported for tests) ─────────────────────────────────────────
@@ -71,13 +69,28 @@ export type SortableModel = {
71
69
  id: string;
72
70
  name?: string;
73
71
  score?: number | null;
72
+ /**
73
+ * Sort tier. Lower = earlier. Default 0.
74
+ * 0 = scored (sort by `score` desc)
75
+ * 1 = benched but unscored (sort by name, between scored and off-catalog)
76
+ * 2 = off-catalog (no bench entry at all → always last)
77
+ * The off-catalog tier exists for models like `openrouter/owl-alpha` that
78
+ * are present in the router but missing from every benchmark source —
79
+ * they should never interleave with the benched-but-unscored tail.
80
+ */
81
+ tier?: number;
74
82
  };
75
83
 
76
84
  export function sortModels<T extends SortableModel>(models: T[]): T[] {
77
85
  return [...models].sort((a, b) => {
78
- const sa = a.score ?? -1;
79
- const sb = b.score ?? -1;
80
- if (sa !== sb) return sb - sa;
86
+ const ta = a.tier ?? 0;
87
+ const tb = b.tier ?? 0;
88
+ if (ta !== tb) return ta - tb;
89
+ if (ta === 0) {
90
+ const sa = a.score ?? -1;
91
+ const sb = b.score ?? -1;
92
+ if (sa !== sb) return sb - sa;
93
+ }
81
94
  return (a.name ?? a.id).localeCompare(b.name ?? b.id);
82
95
  });
83
96
  }
@@ -121,19 +134,35 @@ async function showEnhancedPicker(
121
134
  bench: ReturnType<typeof lookupBenchmark>;
122
135
  // Rank among the user's *available* models, not the global catalog.
123
136
  localRank: number | null;
137
+ // Sort tier: 0 scored, 1 benched-but-unscored, 2 off-catalog.
138
+ tier: 0 | 1 | 2;
124
139
  };
125
- const rows: Row[] = available.map((m) => ({
126
- m,
127
- dev: lookupModelsDev(m.provider, m.id),
128
- bench: lookupBenchmark(m.id),
129
- localRank: null,
130
- }));
131
-
132
- // Sort: by score desc (highest first), unscored last alphabetical
140
+ const rows: Row[] = available.map((m) => {
141
+ const bench = lookupBenchmark(m.id);
142
+ const tier = !bench
143
+ ? 2 // off-catalog → absolute bottom (no rank)
144
+ : bench.overallScore == null
145
+ ? 1 // benched, unscored → middle
146
+ : 0; // scored → top
147
+ return {
148
+ m,
149
+ dev: lookupModelsDev(m.provider, m.id),
150
+ bench,
151
+ localRank: null,
152
+ tier,
153
+ };
154
+ });
155
+
156
+ // Mirror sortModels() — score-desc within tier 0, name-asc otherwise.
133
157
  rows.sort((a, b) => {
134
- const sa = a.bench?.overallScore ?? -1;
135
- const sb = b.bench?.overallScore ?? -1;
136
- if (sa !== sb) return sb - sa;
158
+ const ta = a.tier;
159
+ const tb = b.tier;
160
+ if (ta !== tb) return ta - tb;
161
+ if (ta === 0) {
162
+ const sa = a.bench?.overallScore ?? -1;
163
+ const sb = b.bench?.overallScore ?? -1;
164
+ if (sa !== sb) return sb - sa;
165
+ }
137
166
  return (a.m.name ?? a.m.id).localeCompare(b.m.name ?? b.m.id);
138
167
  });
139
168
 
@@ -148,7 +177,6 @@ async function showEnhancedPicker(
148
177
 
149
178
  const result = await ctx.ui.custom<string | null>(
150
179
  (_tui, theme, _kb, done) => {
151
- const container = new Container();
152
180
  const accent = "accent";
153
181
 
154
182
  // Find max rank width across all benchmarked rows for # padding
@@ -174,7 +202,10 @@ async function showEnhancedPicker(
174
202
  const isCurrent =
175
203
  current && m.provider === current.provider && m.id === current.id;
176
204
 
177
- // Label: marker + muted '#' + bright rank + accent-colored model name
205
+ // Label: marker + rank cell + accent-colored model name.
206
+ // Ranked models show muted '#' + colored rank. Unranked (no
207
+ // modelgrep entry) show a muted em-dash sized to the rank
208
+ // column, so the model name aligns across rows.
178
209
  const marker = isCurrent ? theme.fg(accent, "▶") : " ";
179
210
  let rankPrefix: string;
180
211
  if (localRank) {
@@ -184,7 +215,9 @@ async function showEnhancedPicker(
184
215
  const rankColor = benchScoreColor(bench?.overallScore);
185
216
  rankPrefix = mute("#") + theme.fg(rankColor, rankStr);
186
217
  } else {
187
- rankPrefix = " ".repeat(maxRankWidth + 1);
218
+ // Width = "#" + maxRankWidth chars (e.g. "# " or "#——" for 2-digit ranks).
219
+ const dash = "—".padEnd(maxRankWidth, " ");
220
+ rankPrefix = mute("#") + mute(dash);
188
221
  }
189
222
  // Display model id only; m.provider is routing provider, not part of id.
190
223
  const idColored = theme.fg(accent, m.id);
@@ -236,19 +269,6 @@ async function showEnhancedPicker(
236
269
  )
237
270
  : 0;
238
271
 
239
- container.addChild(new DynamicBorder((s) => theme.fg(accent, s)));
240
- container.addChild(
241
- new Text(theme.fg(accent, theme.bold("󰚩 Select model"))),
242
- );
243
- container.addChild(
244
- new Text(
245
- theme.fg(
246
- "dim",
247
- "context · pricing · coding rank & score from modelgrep.com",
248
- ),
249
- ),
250
- );
251
-
252
272
  // Widest label (visible width, ANSI-stripped) so the model name
253
273
  // column never truncates to "…". Add gap headroom.
254
274
  const widestLabel = items.reduce(
@@ -317,25 +337,34 @@ async function showEnhancedPicker(
317
337
  internal.invalidate();
318
338
  };
319
339
 
320
- container.addChild(new Text(theme.fg("muted", "Search:")));
321
- container.addChild(search);
322
- container.addChild(list);
323
- container.addChild(
324
- new Text(
325
- theme.fg(
326
- "dim",
327
- "fuzzy search · ↑↓ navigate · enter select · esc cancel",
328
- ),
329
- ),
330
- );
331
- container.addChild(new DynamicBorder((s) => theme.fg(accent, s)));
332
-
333
340
  return {
334
341
  render(w: number) {
335
- return container.render(w);
342
+ const mw = modalWidth(w);
343
+ const inner = mw - 4; // CHROME = 2 border + 2 padding
344
+ const lines: string[] = [
345
+ theme.fg(accent, theme.bold("󰈩 Select model")),
346
+ theme.fg(
347
+ "dim",
348
+ "context · pricing · coding rank & score from modelgrep.com",
349
+ ),
350
+ theme.fg("muted", "Search:"),
351
+ ...search.render(inner),
352
+ ...list.render(inner),
353
+ theme.fg(
354
+ "dim",
355
+ "fuzzy search · ↑↓ navigate · enter select · esc cancel",
356
+ ),
357
+ ];
358
+ return frameLines({
359
+ width: mw,
360
+ lines,
361
+ color: (s) => theme.fg(accent, s),
362
+ bg: (s) => theme.bg("customMessageBg", s),
363
+ });
336
364
  },
337
365
  invalidate() {
338
- container.invalidate();
366
+ list.invalidate();
367
+ search.invalidate();
339
368
  },
340
369
  handleInput(data: string) {
341
370
  // Detect keys via pi-tui's own parser — the same recognition
@@ -350,10 +379,11 @@ async function showEnhancedPicker(
350
379
  search.handleInput?.(data);
351
380
  applyFuzzy(search.getValue?.() ?? "");
352
381
  }
353
- container.invalidate();
382
+ list.invalidate();
354
383
  },
355
384
  };
356
385
  },
386
+ { overlay: true },
357
387
  );
358
388
 
359
389
  if (!result) return;