@xynogen/pix-models 0.2.3 → 0.2.4

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/models.ts +168 -162
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-models",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Pi extension — enhanced /models picker with BenchLM ranks",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/models.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  terminalModalHeight,
30
30
  } from "@xynogen/pix-pretty/modal-frame";
31
31
  import { dotJoin } from "@xynogen/pix-pretty/utils";
32
+ import { SPINNER } from "@xynogen/pix-pretty/widget-format";
32
33
  import { patchOutBuiltinModelCommand } from "./patch-builtin";
33
34
 
34
35
  // ─── Pure logic (exported for tests) ─────────────────────────────────────────
@@ -237,7 +238,7 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
237
238
  getAvailable(): AvailableModels | Promise<AvailableModels>;
238
239
  };
239
240
  registry.refresh?.();
240
- const available = await registry.getAvailable();
241
+ let available = await registry.getAvailable();
241
242
  if (available.length === 0) {
242
243
  ctx.ui.notify("No models with configured auth.", "warning");
243
244
  return;
@@ -255,197 +256,193 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
255
256
  // Sort tier: 0 scored, 1 benched-but-unscored, 2 off-catalog.
256
257
  tier: 0 | 1 | 2;
257
258
  };
258
- const rows: Row[] = available.map((m) => {
259
- const bench = lookupBenchmark(m.id);
260
- const tier = !bench
261
- ? 2 // off-catalog → absolute bottom (no rank)
262
- : bench.overallScore == null
263
- ? 1 // benched, unscored → middle
264
- : 0; // scoredtop
265
- return {
266
- m,
267
- dev: lookupModelsDev(m.provider, m.id),
268
- bench,
269
- localRank: null,
270
- tier,
271
- };
272
- });
273
-
274
- // Mirror sortModels() — score-desc within tier 0, name-asc otherwise.
275
- rows.sort((a, b) => {
276
- const ta = a.tier;
277
- const tb = b.tier;
278
- if (ta !== tb) return ta - tb;
279
- if (ta === 0) {
280
- const sa = a.bench?.overallScore ?? -1;
281
- const sb = b.bench?.overallScore ?? -1;
282
- if (sa !== sb) return sb - sa;
283
- }
284
- return (a.m.name ?? a.m.id).localeCompare(b.m.name ?? b.m.id);
285
- });
286
-
287
- // Local rank = position among scored available models (best pickable = #1).
288
- let localRank = 0;
289
- for (const r of rows) if (r.bench) r.localRank = ++localRank;
259
+ const buildRows = (models: Awaited<AvailableModels>): Row[] => {
260
+ const rows: Row[] = models.map((m) => {
261
+ const bench = lookupBenchmark(m.id);
262
+ const tier = !bench
263
+ ? 2 // off-catalog → absolute bottom (no rank)
264
+ : bench.overallScore == null
265
+ ? 1 // benched, unscored middle
266
+ : 0; // scored → top
267
+ return {
268
+ m,
269
+ dev: lookupModelsDev(m.provider, m.id),
270
+ bench,
271
+ localRank: null,
272
+ tier,
273
+ } satisfies Row;
274
+ });
275
+
276
+ // Mirror sortModels() — score-desc within tier 0, name-asc otherwise.
277
+ rows.sort((a, b) => {
278
+ if (a.tier !== b.tier) return a.tier - b.tier;
279
+ if (a.tier === 0) {
280
+ const sa = a.bench?.overallScore ?? -1;
281
+ const sb = b.bench?.overallScore ?? -1;
282
+ if (sa !== sb) return sb - sa;
283
+ }
284
+ return (a.m.name ?? a.m.id).localeCompare(b.m.name ?? b.m.id);
285
+ });
290
286
 
291
- // Show all models (no deduplication)
292
- const dedupedRows = rows;
287
+ // Local rank = position among scored available models (best pickable = #1).
288
+ let localRank = 0;
289
+ for (const row of rows) if (row.bench) row.localRank = ++localRank;
290
+ return rows;
291
+ };
293
292
 
294
293
  // items built inside the custom() factory so we have theme access for colors
295
294
 
296
295
  const result = await ctx.ui.custom<string | null>(
297
296
  (tui, theme, _kb, done) => {
298
297
  const accent = "accent";
299
-
300
- // Find max rank width across all benchmarked rows for # padding
301
- const maxRankWidth = Math.max(
302
- ...dedupedRows.map((r) => (r.localRank ? String(r.localRank).length : 0)),
303
- 1,
304
- );
305
-
306
- // Widest cost string so the cost column pads to a common width and the
307
- // following ⚡score/stars stay column-aligned (e.g. "10.00/50.00" is 11
308
- // chars — a fixed pad of 10 shifted those rows right by one).
309
- const maxCostWidth = Math.max(
310
- ...dedupedRows.map((r) => fmtCost(r.dev).length),
311
- "free".length,
312
- );
313
-
314
- // Mute low-info parts (separators, padding, #, ☆) so the actual values pop.
315
298
  const mute = (s: string) => theme.fg("muted", s);
316
299
  const guide = (key: string, action: string) =>
317
300
  theme.fg("text", key) + theme.fg("muted", ` ${action}`);
318
301
  const guideSep = theme.fg("muted", " · ");
319
302
 
320
- // Track rank per item value so fuzzy results can prioritize ranked models.
321
- const rankByValue = new Map<string, number>();
322
- for (const { m, localRank } of dedupedRows) {
323
- if (localRank) rankByValue.set(`${m.provider}/${m.id}`, localRank);
324
- }
325
-
326
- // Clean search haystacks — labels are ANSI-laden and carry the rank cell,
327
- // so matching runs against raw id+name instead (see filterModelItems).
328
- const searchTextByValue = new Map<string, string>();
329
- const normalizedByValue = new Map<string, string>();
330
- for (const { m } of dedupedRows) {
331
- const value = `${m.provider}/${m.id}`;
332
- const text = `${m.id} ${m.name ?? ""}`;
333
- searchTextByValue.set(value, text);
334
- normalizedByValue.set(value, normalizeModelText(text));
335
- }
336
-
337
- const items: SelectItem[] = dedupedRows.map(({ m, dev, bench, localRank }) => {
338
- const isCurrent = current && m.provider === current.provider && m.id === current.id;
339
-
340
- // Label: marker + rank cell + accent-colored model name.
341
- // Ranked models show muted '#' + colored rank. Unranked (no
342
- // modelgrep entry) show a muted em-dash sized to the rank
343
- // column, so the model name aligns across rows.
344
- const marker = isCurrent ? theme.fg(accent, "▶") : " ";
345
- let rankPrefix: string;
346
- if (localRank) {
347
- const rankStr = String(localRank).padEnd(maxRankWidth);
348
- // Color rank by the model's bench score (same scale as ⚡score),
349
- // not by list position — keeps the two colors consistent.
350
- const rankColor = benchScoreColor(bench?.overallScore);
351
- rankPrefix = mute("#") + theme.fg(rankColor, rankStr);
352
- } else {
353
- // Width = "#" + maxRankWidth chars (e.g. "# " or "#——" for 2-digit ranks).
354
- const dash = "—".padEnd(maxRankWidth, " ");
355
- rankPrefix = mute("#") + mute(dash);
356
- }
357
- // Display model id only; m.provider is routing provider, not part of id.
358
- // Color the name by bench score so high-scoring models visually pop.
359
- const nameColor = bench ? benchScoreColor(bench.overallScore) : accent;
360
- const idColored = theme.fg(nameColor, m.id);
361
- const label = `${marker} ${rankPrefix} ${idColored}`;
362
-
363
- // Description: ctx · cost · score stars
364
- // Colors: ctx muted · cost success (free muted) · score+stars warning
365
- // Context: provider's `contextWindow` (source of truth) → fallback to modelgrep `dev.limit.context`.
366
- const ctxRaw = fmtCtx(
367
- resolveContextWindow(m as { contextWindow?: number }, dev?.limit?.context),
303
+ const buildPickerData = (sourceRows: Row[]) => {
304
+ const maxRankWidth = Math.max(
305
+ ...sourceRows.map((row) => (row.localRank ? String(row.localRank).length : 0)),
306
+ 1,
368
307
  );
369
- const ctxStr = mute(ctxRaw.padStart(4));
370
- const rawCost = fmtCost(dev);
371
- let costSeg: string;
372
- if (rawCost === "—") {
373
- costSeg = theme.fg("muted", "—".padEnd(maxCostWidth));
374
- } else if (rawCost === "free") {
375
- costSeg = mute("free".padEnd(maxCostWidth));
376
- } else {
377
- costSeg = theme.fg("success", rawCost.padEnd(maxCostWidth));
378
- }
379
- let benchSeg = "";
380
- if (bench) {
381
- const score = bench.overallScore ?? "?";
382
- const s = bench.overallScore;
383
- const scoreColor = benchScoreColor(s);
384
- let filled = 1;
385
- if (typeof s === "number") {
386
- if (s >= 90) filled = 5;
387
- else if (s >= 80) filled = 4;
388
- else if (s >= 70) filled = 3;
389
- else if (s >= 50) filled = 2;
390
- }
391
- const starBar = theme.fg(scoreColor, "★".repeat(filled)) + mute("☆".repeat(5 - filled));
392
- benchSeg = `⚡${theme.fg(scoreColor, String(score))} ${starBar}`;
308
+ const maxCostWidth = Math.max(
309
+ ...sourceRows.map((row) => fmtCost(row.dev).length),
310
+ "free".length,
311
+ );
312
+ const rankByValue = new Map<string, number>();
313
+ const searchTextByValue = new Map<string, string>();
314
+ const normalizedByValue = new Map<string, string>();
315
+ for (const { m, localRank } of sourceRows) {
316
+ const value = `${m.provider}/${m.id}`;
317
+ const text = `${m.id} ${m.name ?? ""}`;
318
+ if (localRank) rankByValue.set(value, localRank);
319
+ searchTextByValue.set(value, text);
320
+ normalizedByValue.set(value, normalizeModelText(text));
393
321
  }
394
- const desc = dotJoin([ctxStr, costSeg, benchSeg], mute);
395
322
 
323
+ const items: SelectItem[] = sourceRows.map(({ m, dev, bench, localRank }) => {
324
+ const isCurrent = current && m.provider === current.provider && m.id === current.id;
325
+ const marker = isCurrent ? theme.fg(accent, "▶") : " ";
326
+ const rankPrefix = localRank
327
+ ? mute("#") +
328
+ theme.fg(benchScoreColor(bench?.overallScore), String(localRank).padEnd(maxRankWidth))
329
+ : mute("#") + mute("—".padEnd(maxRankWidth, " "));
330
+ const nameColor = bench ? benchScoreColor(bench.overallScore) : accent;
331
+ const label = `${marker} ${rankPrefix} ${theme.fg(nameColor, m.id)}`;
332
+ const ctxRaw = fmtCtx(
333
+ resolveContextWindow(m as { contextWindow?: number }, dev?.limit?.context),
334
+ );
335
+ const rawCost = fmtCost(dev);
336
+ const costSeg =
337
+ rawCost === "—"
338
+ ? theme.fg("muted", "—".padEnd(maxCostWidth))
339
+ : rawCost === "free"
340
+ ? mute("free".padEnd(maxCostWidth))
341
+ : theme.fg("success", rawCost.padEnd(maxCostWidth));
342
+ let benchSeg = "";
343
+ if (bench) {
344
+ const score = bench.overallScore ?? "?";
345
+ const scoreColor = benchScoreColor(bench.overallScore);
346
+ const { filled, empty } = benchStars(bench.overallScore);
347
+ const stars = theme.fg(scoreColor, "★".repeat(filled)) + mute("☆".repeat(empty));
348
+ benchSeg = `⚡${theme.fg(scoreColor, String(score))} ${stars}`;
349
+ }
350
+ return {
351
+ value: `${m.provider}/${m.id}`,
352
+ label,
353
+ description: dotJoin([mute(ctxRaw.padStart(4)), costSeg, benchSeg], mute),
354
+ };
355
+ });
396
356
  return {
397
- value: `${m.provider}/${m.id}`,
398
- label,
399
- description: desc,
357
+ items,
358
+ rankByValue,
359
+ searchTextByValue,
360
+ normalizedByValue,
361
+ widestLabel: items.reduce((width, item) => Math.max(width, visibleWidth(item.label)), 0),
400
362
  };
401
- });
363
+ };
402
364
 
365
+ let pickerData = buildPickerData(buildRows(available));
403
366
  const currentIdx = current
404
- ? items.findIndex((it) => it.value === `${current.provider}/${current.id}`)
367
+ ? pickerData.items.findIndex((item) => item.value === `${current.provider}/${current.id}`)
405
368
  : 0;
406
-
407
- // Widest label (visible width, ANSI-stripped) so the model name
408
- // column never truncates to "…". Add gap headroom.
409
- const widestLabel = items.reduce((w, it) => Math.max(w, visibleWidth(it.label)), 0);
410
-
411
369
  const search = new Input();
412
370
  const list = new SelectList(
413
- items,
414
- Math.max(1, items.length),
371
+ pickerData.items,
372
+ Math.max(1, pickerData.items.length),
415
373
  {
416
- selectedPrefix: (t) => theme.fg(accent, t),
417
- selectedText: (t) => theme.fg(accent, t),
418
- description: (t) => t, // raw — per-segment colors set in items.map
419
- scrollInfo: (t) => theme.fg("muted", t),
420
- noMatch: (t) => theme.fg("warning", t),
374
+ selectedPrefix: (text) => theme.fg(accent, text),
375
+ selectedText: (text) => theme.fg(accent, text),
376
+ description: (text) => text,
377
+ scrollInfo: (text) => theme.fg("muted", text),
378
+ noMatch: (text) => theme.fg("warning", text),
421
379
  },
422
380
  {
423
- minPrimaryColumnWidth: widestLabel + 2,
424
- maxPrimaryColumnWidth: widestLabel + 2,
381
+ minPrimaryColumnWidth: pickerData.widestLabel + 2,
382
+ maxPrimaryColumnWidth: pickerData.widestLabel + 2,
425
383
  },
426
384
  );
427
385
  if (currentIdx >= 0) list.setSelectedIndex(currentIdx);
428
-
429
386
  list.onSelect = (item) => done(item.value);
430
387
  list.onCancel = () => done(null);
431
388
  search.onEscape = () => done(null);
432
389
 
433
- const applyFuzzy = (query: string) => {
434
- // SAFETY: SelectList exposes these stable fields internally for in-place filtering.
435
- const internal = list as unknown as {
436
- items: SelectItem[];
437
- filteredItems: SelectItem[];
438
- selectedIndex: number;
439
- invalidate(): void;
440
- };
441
- const next = filterModelItems(internal.items, query, {
442
- rankByValue,
443
- searchTextByValue,
444
- normalizedByValue,
445
- });
446
- internal.filteredItems = next;
447
- internal.selectedIndex = 0;
448
- internal.invalidate();
390
+ type ListInternal = {
391
+ items: SelectItem[];
392
+ filteredItems: SelectItem[];
393
+ selectedIndex: number;
394
+ maxVisible: number;
395
+ layout: { minPrimaryColumnWidth: number; maxPrimaryColumnWidth: number };
396
+ invalidate(): void;
397
+ };
398
+ // SAFETY: SelectList has no public item-replacement API; runtime fields are stable and
399
+ // already used above for custom filtering. Keep this cast local to picker updates.
400
+ const listInternal = list as unknown as ListInternal;
401
+ const applyFuzzy = (query: string, selectedValue?: string) => {
402
+ listInternal.filteredItems = filterModelItems(listInternal.items, query, pickerData);
403
+ const selectedIndex = selectedValue
404
+ ? listInternal.filteredItems.findIndex((item) => item.value === selectedValue)
405
+ : -1;
406
+ listInternal.selectedIndex = Math.max(0, selectedIndex);
407
+ listInternal.invalidate();
408
+ };
409
+
410
+ const pager = new ModalPager();
411
+ let reloading = false;
412
+ let spinnerFrame = 0;
413
+ const reload = async () => {
414
+ if (reloading) return;
415
+ reloading = true;
416
+ const spinnerTimer = setInterval(() => {
417
+ spinnerFrame = (spinnerFrame + 1) % SPINNER.length;
418
+ tui.requestRender();
419
+ }, 120);
420
+ tui.requestRender();
421
+ try {
422
+ const selectedValue = list.getSelectedItem()?.value;
423
+ registry.refresh?.();
424
+ const refreshed = await registry.getAvailable();
425
+ if (refreshed.length === 0) {
426
+ ctx.ui.notify("No models with configured auth.", "warning");
427
+ return;
428
+ }
429
+ available = refreshed;
430
+ pickerData = buildPickerData(buildRows(available));
431
+ listInternal.items = pickerData.items;
432
+ listInternal.maxVisible = Math.max(1, pickerData.items.length);
433
+ listInternal.layout = {
434
+ minPrimaryColumnWidth: pickerData.widestLabel + 2,
435
+ maxPrimaryColumnWidth: pickerData.widestLabel + 2,
436
+ };
437
+ applyFuzzy(search.getValue?.() ?? "", selectedValue);
438
+ pager.followSelection();
439
+ } catch (error) {
440
+ ctx.ui.notify(`Failed to reload models: ${String(error)}`, "error");
441
+ } finally {
442
+ clearInterval(spinnerTimer);
443
+ reloading = false;
444
+ tui.requestRender();
445
+ }
449
446
  };
450
447
 
451
448
  // Live thinking-level readout. ←/→ mutates the session immediately via
@@ -457,7 +454,6 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
457
454
  // We seed it from the getter, then advance it in lock-step with each
458
455
  // setThinkingLevel() call and reconcile back to the getter when present.
459
456
  let localLevel = pi.getThinkingLevel?.() ?? "";
460
- const pager = new ModalPager();
461
457
  const thinkLine = () => {
462
458
  const live = pi.getThinkingLevel?.();
463
459
  const resolved = live ?? localLevel;
@@ -484,7 +480,9 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
484
480
  minHeight: MIN_MODAL_HEIGHT,
485
481
  header: [
486
482
  theme.fg(accent, theme.bold(`${icon("picker.model")} Select model`)),
487
- theme.fg("dim", "context · pricing · coding rank & score from modelgrep.com"),
483
+ reloading
484
+ ? theme.fg("accent", `${SPINNER[spinnerFrame] ?? ""} Reloading models…`)
485
+ : theme.fg("dim", "context · pricing · coding rank & score from modelgrep.com"),
488
486
  thinkLine(),
489
487
  ...search.render(inner),
490
488
  "",
@@ -504,6 +502,8 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
504
502
  guideSep +
505
503
  guide("←/→", "thinking") +
506
504
  guideSep +
505
+ guide("^r", "reload") +
506
+ guideSep +
507
507
  guide("enter", "select") +
508
508
  guideSep +
509
509
  guide("esc", "cancel"),
@@ -527,6 +527,12 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
527
527
  const isNav = matchesKey(data, "up") || matchesKey(data, "down");
528
528
  // ←/→ tunes the ACTIVE session model's thinking level. setThinkingLevel
529
529
  // clamps to model capability, so unsupported rungs land on the nearest allowed.
530
+ // ctrl+r refreshes and replaces list data without disposing the overlay.
531
+ if (matchesKey(data, Key.ctrl("r"))) {
532
+ void reload();
533
+ return;
534
+ }
535
+ if (reloading) return;
530
536
  let dir: -1 | 1 | 0 = 0;
531
537
  if (matchesKey(data, Key.left)) dir = -1;
532
538
  else if (matchesKey(data, Key.right)) dir = 1;