@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.
- package/package.json +1 -1
- package/src/models.ts +168 -162
package/package.json
CHANGED
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
|
-
|
|
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
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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((
|
|
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: (
|
|
417
|
-
selectedText: (
|
|
418
|
-
description: (
|
|
419
|
-
scrollInfo: (
|
|
420
|
-
noMatch: (
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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;
|