@xynogen/pix-models 0.2.2 → 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 +175 -166
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-models",
3
- "version": "0.2.2",
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) ─────────────────────────────────────────
@@ -231,12 +232,13 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
231
232
  // ModelRegistry instance (verified in runner.js) with refresh() and an
232
233
  // async-capable getAvailable(). Reach through the narrowed type.
233
234
  type AvailableModels = ReturnType<typeof ctx.modelRegistry.getAvailable>;
235
+ // SAFETY: Runtime ModelRegistry includes refresh and async availability missing from public types.
234
236
  const registry = ctx.modelRegistry as unknown as {
235
237
  refresh?: () => void;
236
238
  getAvailable(): AvailableModels | Promise<AvailableModels>;
237
239
  };
238
240
  registry.refresh?.();
239
- const available = await registry.getAvailable();
241
+ let available = await registry.getAvailable();
240
242
  if (available.length === 0) {
241
243
  ctx.ui.notify("No models with configured auth.", "warning");
242
244
  return;
@@ -254,196 +256,193 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
254
256
  // Sort tier: 0 scored, 1 benched-but-unscored, 2 off-catalog.
255
257
  tier: 0 | 1 | 2;
256
258
  };
257
- const rows: Row[] = available.map((m) => {
258
- const bench = lookupBenchmark(m.id);
259
- const tier = !bench
260
- ? 2 // off-catalog → absolute bottom (no rank)
261
- : bench.overallScore == null
262
- ? 1 // benched, unscored → middle
263
- : 0; // scoredtop
264
- return {
265
- m,
266
- dev: lookupModelsDev(m.provider, m.id),
267
- bench,
268
- localRank: null,
269
- tier,
270
- };
271
- });
272
-
273
- // Mirror sortModels() — score-desc within tier 0, name-asc otherwise.
274
- rows.sort((a, b) => {
275
- const ta = a.tier;
276
- const tb = b.tier;
277
- if (ta !== tb) return ta - tb;
278
- if (ta === 0) {
279
- const sa = a.bench?.overallScore ?? -1;
280
- const sb = b.bench?.overallScore ?? -1;
281
- if (sa !== sb) return sb - sa;
282
- }
283
- return (a.m.name ?? a.m.id).localeCompare(b.m.name ?? b.m.id);
284
- });
285
-
286
- // Local rank = position among scored available models (best pickable = #1).
287
- let localRank = 0;
288
- 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
+ });
289
286
 
290
- // Show all models (no deduplication)
291
- 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
+ };
292
292
 
293
293
  // items built inside the custom() factory so we have theme access for colors
294
294
 
295
295
  const result = await ctx.ui.custom<string | null>(
296
296
  (tui, theme, _kb, done) => {
297
297
  const accent = "accent";
298
-
299
- // Find max rank width across all benchmarked rows for # padding
300
- const maxRankWidth = Math.max(
301
- ...dedupedRows.map((r) => (r.localRank ? String(r.localRank).length : 0)),
302
- 1,
303
- );
304
-
305
- // Widest cost string so the cost column pads to a common width and the
306
- // following ⚡score/stars stay column-aligned (e.g. "10.00/50.00" is 11
307
- // chars — a fixed pad of 10 shifted those rows right by one).
308
- const maxCostWidth = Math.max(
309
- ...dedupedRows.map((r) => fmtCost(r.dev).length),
310
- "free".length,
311
- );
312
-
313
- // Mute low-info parts (separators, padding, #, ☆) so the actual values pop.
314
298
  const mute = (s: string) => theme.fg("muted", s);
315
299
  const guide = (key: string, action: string) =>
316
- theme.fg("text", key) + theme.fg("dim", ` ${action}`);
317
- const guideSep = theme.fg("dim", " · ");
318
-
319
- // Track rank per item value so fuzzy results can prioritize ranked models.
320
- const rankByValue = new Map<string, number>();
321
- for (const { m, localRank } of dedupedRows) {
322
- if (localRank) rankByValue.set(`${m.provider}/${m.id}`, localRank);
323
- }
324
-
325
- // Clean search haystacks — labels are ANSI-laden and carry the rank cell,
326
- // so matching runs against raw id+name instead (see filterModelItems).
327
- const searchTextByValue = new Map<string, string>();
328
- const normalizedByValue = new Map<string, string>();
329
- for (const { m } of dedupedRows) {
330
- const value = `${m.provider}/${m.id}`;
331
- const text = `${m.id} ${m.name ?? ""}`;
332
- searchTextByValue.set(value, text);
333
- normalizedByValue.set(value, normalizeModelText(text));
334
- }
300
+ theme.fg("text", key) + theme.fg("muted", ` ${action}`);
301
+ const guideSep = theme.fg("muted", " · ");
335
302
 
336
- const items: SelectItem[] = dedupedRows.map(({ m, dev, bench, localRank }) => {
337
- const isCurrent = current && m.provider === current.provider && m.id === current.id;
338
-
339
- // Label: marker + rank cell + accent-colored model name.
340
- // Ranked models show muted '#' + colored rank. Unranked (no
341
- // modelgrep entry) show a muted em-dash sized to the rank
342
- // column, so the model name aligns across rows.
343
- const marker = isCurrent ? theme.fg(accent, "▶") : " ";
344
- let rankPrefix: string;
345
- if (localRank) {
346
- const rankStr = String(localRank).padEnd(maxRankWidth);
347
- // Color rank by the model's bench score (same scale as ⚡score),
348
- // not by list position — keeps the two colors consistent.
349
- const rankColor = benchScoreColor(bench?.overallScore);
350
- rankPrefix = mute("#") + theme.fg(rankColor, rankStr);
351
- } else {
352
- // Width = "#" + maxRankWidth chars (e.g. "# " or "#——" for 2-digit ranks).
353
- const dash = "—".padEnd(maxRankWidth, " ");
354
- rankPrefix = mute("#") + mute(dash);
355
- }
356
- // Display model id only; m.provider is routing provider, not part of id.
357
- // Color the name by bench score so high-scoring models visually pop.
358
- const nameColor = bench ? benchScoreColor(bench.overallScore) : accent;
359
- const idColored = theme.fg(nameColor, m.id);
360
- const label = `${marker} ${rankPrefix} ${idColored}`;
361
-
362
- // Description: ctx · cost · score stars
363
- // Colors: ctx muted · cost success (free muted) · score+stars warning
364
- // Context: provider's `contextWindow` (source of truth) → fallback to modelgrep `dev.limit.context`.
365
- const ctxRaw = fmtCtx(
366
- 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,
367
307
  );
368
- const ctxStr = mute(ctxRaw.padStart(4));
369
- const rawCost = fmtCost(dev);
370
- let costSeg: string;
371
- if (rawCost === "—") {
372
- costSeg = theme.fg("dim", "—".padEnd(maxCostWidth));
373
- } else if (rawCost === "free") {
374
- costSeg = mute("free".padEnd(maxCostWidth));
375
- } else {
376
- costSeg = theme.fg("success", rawCost.padEnd(maxCostWidth));
377
- }
378
- let benchSeg = "";
379
- if (bench) {
380
- const score = bench.overallScore ?? "?";
381
- const s = bench.overallScore;
382
- const scoreColor = benchScoreColor(s);
383
- let filled = 1;
384
- if (typeof s === "number") {
385
- if (s >= 90) filled = 5;
386
- else if (s >= 80) filled = 4;
387
- else if (s >= 70) filled = 3;
388
- else if (s >= 50) filled = 2;
389
- }
390
- const starBar = theme.fg(scoreColor, "★".repeat(filled)) + mute("☆".repeat(5 - filled));
391
- 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));
392
321
  }
393
- const desc = dotJoin([ctxStr, costSeg, benchSeg], mute);
394
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
+ });
395
356
  return {
396
- value: `${m.provider}/${m.id}`,
397
- label,
398
- description: desc,
357
+ items,
358
+ rankByValue,
359
+ searchTextByValue,
360
+ normalizedByValue,
361
+ widestLabel: items.reduce((width, item) => Math.max(width, visibleWidth(item.label)), 0),
399
362
  };
400
- });
363
+ };
401
364
 
365
+ let pickerData = buildPickerData(buildRows(available));
402
366
  const currentIdx = current
403
- ? items.findIndex((it) => it.value === `${current.provider}/${current.id}`)
367
+ ? pickerData.items.findIndex((item) => item.value === `${current.provider}/${current.id}`)
404
368
  : 0;
405
-
406
- // Widest label (visible width, ANSI-stripped) so the model name
407
- // column never truncates to "…". Add gap headroom.
408
- const widestLabel = items.reduce((w, it) => Math.max(w, visibleWidth(it.label)), 0);
409
-
410
369
  const search = new Input();
411
370
  const list = new SelectList(
412
- items,
413
- Math.max(1, items.length),
371
+ pickerData.items,
372
+ Math.max(1, pickerData.items.length),
414
373
  {
415
- selectedPrefix: (t) => theme.fg(accent, t),
416
- selectedText: (t) => theme.fg(accent, t),
417
- description: (t) => t, // raw — per-segment colors set in items.map
418
- scrollInfo: (t) => theme.fg("dim", t),
419
- 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),
420
379
  },
421
380
  {
422
- minPrimaryColumnWidth: widestLabel + 2,
423
- maxPrimaryColumnWidth: widestLabel + 2,
381
+ minPrimaryColumnWidth: pickerData.widestLabel + 2,
382
+ maxPrimaryColumnWidth: pickerData.widestLabel + 2,
424
383
  },
425
384
  );
426
385
  if (currentIdx >= 0) list.setSelectedIndex(currentIdx);
427
-
428
386
  list.onSelect = (item) => done(item.value);
429
387
  list.onCancel = () => done(null);
430
388
  search.onEscape = () => done(null);
431
389
 
432
- const applyFuzzy = (query: string) => {
433
- const internal = list as unknown as {
434
- items: SelectItem[];
435
- filteredItems: SelectItem[];
436
- selectedIndex: number;
437
- invalidate(): void;
438
- };
439
- const next = filterModelItems(internal.items, query, {
440
- rankByValue,
441
- searchTextByValue,
442
- normalizedByValue,
443
- });
444
- internal.filteredItems = next;
445
- internal.selectedIndex = 0;
446
- 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
+ }
447
446
  };
448
447
 
449
448
  // Live thinking-level readout. ←/→ mutates the session immediately via
@@ -455,7 +454,6 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
455
454
  // We seed it from the getter, then advance it in lock-step with each
456
455
  // setThinkingLevel() call and reconcile back to the getter when present.
457
456
  let localLevel = pi.getThinkingLevel?.() ?? "";
458
- const pager = new ModalPager();
459
457
  const thinkLine = () => {
460
458
  const live = pi.getThinkingLevel?.();
461
459
  const resolved = live ?? localLevel;
@@ -464,11 +462,11 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
464
462
  ? theme.getThinkingBorderColor(resolved)(label)
465
463
  : theme.fg("dim", label);
466
464
  return (
467
- theme.fg("muted", "Thinking: ") +
465
+ theme.fg("dim", "Thinking: ") +
468
466
  coloredLabel +
469
- theme.fg("dim", " (") +
467
+ theme.fg("muted", " (") +
470
468
  guide("←/→", "adjust") +
471
- theme.fg("dim", ")")
469
+ theme.fg("muted", ")")
472
470
  );
473
471
  };
474
472
 
@@ -482,13 +480,16 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
482
480
  minHeight: MIN_MODAL_HEIGHT,
483
481
  header: [
484
482
  theme.fg(accent, theme.bold(`${icon("picker.model")} Select model`)),
485
- 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"),
486
486
  thinkLine(),
487
487
  ...search.render(inner),
488
488
  "",
489
489
  ],
490
490
  body: list.render(inner),
491
491
  selectedBodyRange: (() => {
492
+ // SAFETY: SelectList tracks selectedIndex internally for pager synchronization.
492
493
  const internal = list as unknown as { selectedIndex: number };
493
494
  return pager.selectedRange({
494
495
  start: internal.selectedIndex,
@@ -501,6 +502,8 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
501
502
  guideSep +
502
503
  guide("←/→", "thinking") +
503
504
  guideSep +
505
+ guide("^r", "reload") +
506
+ guideSep +
504
507
  guide("enter", "select") +
505
508
  guideSep +
506
509
  guide("esc", "cancel"),
@@ -524,6 +527,12 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
524
527
  const isNav = matchesKey(data, "up") || matchesKey(data, "down");
525
528
  // ←/→ tunes the ACTIVE session model's thinking level. setThinkingLevel
526
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;
527
536
  let dir: -1 | 1 | 0 = 0;
528
537
  if (matchesKey(data, Key.left)) dir = -1;
529
538
  else if (matchesKey(data, Key.right)) dir = 1;