@pi-unipi/fusion 2.18.0 → 2.18.1

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 +2 -2
  2. package/src/picker.ts +61 -30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/fusion",
3
- "version": "2.18.0",
3
+ "version": "2.18.1",
4
4
  "description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@pi-unipi/core": "2.18.0",
36
- "@pi-unipi/subagents": "2.18.0"
36
+ "@pi-unipi/subagents": "2.18.1"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-ai": "^0.84.0",
package/src/picker.ts CHANGED
@@ -14,10 +14,11 @@
14
14
  * $10 / 1M $0.25 / 1M $50 / 1M $0.2 / 1M $1.2 / 1M
15
15
  * ↑/↓ select · ←/→ effort · tab lead · Enter confirm · esc cancel
16
16
  *
17
- * Row order: the active selection pinned first, then the Fusion row (when a
18
- * pair is configured), then recent (≤5, MRU), then the preset models, then
19
- * EVERY other available model — the catalogue is never hidden, the preset
20
- * only controls ordering. Typing filters all rows except the pinned one.
17
+ * Row order: the active selection pinned first, then the always-visible Fusion
18
+ * row (disabled with a setup hint when no pair is configured), then recent
19
+ * (≤5, MRU), then the preset models, then EVERY other available model — the
20
+ * catalogue is never hidden, the preset only controls ordering. Typing filters
21
+ * all rows except the pinned one.
21
22
  *
22
23
  * ←/→ steps the highlighted row's effort. Per-model effort is remembered for
23
24
  * plain model rows; the Fusion row keeps its own lead/sidekick efforts so
@@ -219,7 +220,7 @@ export class ModelPicker {
219
220
  out.push({ kind: "model", key: active.model });
220
221
  seen.add(active.model);
221
222
  }
222
- if (!pinnedFusion && this.fusionAvailable()) out.push({ kind: "fusion" });
223
+ if (!pinnedFusion) out.push({ kind: "fusion" });
223
224
 
224
225
  const ordered: ModelKey[] = [
225
226
  ...this.state.recent,
@@ -268,6 +269,18 @@ export class ModelPicker {
268
269
  return;
269
270
  }
270
271
 
272
+ const disabledFusion = row?.kind === "fusion" && !this.fusionAvailable();
273
+ if (
274
+ disabledFusion &&
275
+ (matchesKey(data, Key.tab) ||
276
+ matchesKey(data, Key.left) ||
277
+ matchesKey(data, Key.right) ||
278
+ matchesKey(data, Key.enter) ||
279
+ data === "\r")
280
+ ) {
281
+ return;
282
+ }
283
+
271
284
  if (inDropdown) {
272
285
  const items = this.dropdownItems();
273
286
  if (matchesKey(data, Key.up)) {
@@ -406,11 +419,12 @@ export class ModelPicker {
406
419
  private renderRow(row: Row, highlighted: boolean, width: number): string {
407
420
  const t = this.theme;
408
421
  const pointer = highlighted ? t.fg("accent", "❭") : t.fg("dim", "·");
422
+ const disabledFusion = row.kind === "fusion" && !this.fusionAvailable();
409
423
  // The Fusion composite gets the check when it is the active selection —
410
424
  // same affordance a single active model gets on its own row.
411
425
  const marker =
412
426
  row.kind === "fusion"
413
- ? this.state.active?.kind === "fusion"
427
+ ? !disabledFusion && this.state.active?.kind === "fusion"
414
428
  ? t.fg("success", "✓")
415
429
  : " "
416
430
  : this.markerFor(row.key);
@@ -418,9 +432,13 @@ export class ModelPicker {
418
432
  const nameRaw = row.kind === "fusion" ? "Fusion" : this.nameOf(row.key, NAME_COL - 1);
419
433
  const name =
420
434
  row.kind === "fusion"
421
- ? highlighted
422
- ? t.fg("accent", t.bold(nameRaw))
423
- : t.fg("text", nameRaw)
435
+ ? disabledFusion
436
+ ? highlighted
437
+ ? t.fg("muted", t.bold(nameRaw))
438
+ : t.fg("dim", nameRaw)
439
+ : highlighted
440
+ ? t.fg("accent", t.bold(nameRaw))
441
+ : t.fg("text", nameRaw)
424
442
  : working
425
443
  ? t.fg("accent", t.bold(nameRaw))
426
444
  : highlighted
@@ -431,25 +449,33 @@ export class ModelPicker {
431
449
  const badgeGlyph = badge === undefined ? "" : ` ${t.fg(badge === "new" ? "success" : badge === "promotion" ? "accent" : "warning", "✱")}`;
432
450
 
433
451
  const level = row.kind === "fusion" ? this.fusionLeadEffort : this.effortFor(row.key);
434
- const arrowsOn = highlighted && this.focus === "effort";
452
+ const arrowsOn = !disabledFusion && highlighted && this.focus === "effort";
435
453
  const left = arrowsOn ? t.fg("accent", "←") : " ";
436
454
  const right = arrowsOn ? t.fg("accent", "→") : " ";
437
- const label = highlighted ? t.fg("accent", effortLabel(level)) : t.fg("muted", effortLabel(level));
455
+ const label = disabledFusion
456
+ ? t.fg("dim", effortLabel(level))
457
+ : highlighted
458
+ ? t.fg("accent", effortLabel(level))
459
+ : t.fg("muted", effortLabel(level));
438
460
 
439
- let line = `${pointer} ${marker} ${pad(`${name}${badgeGlyph}`, NAME_COL)} ${left} ${this.bar(level, highlighted)} ${right} ${pad(label, 8)}`;
461
+ let line = `${pointer} ${marker} ${pad(`${name}${badgeGlyph}`, NAME_COL)} ${left} ${this.bar(level, !disabledFusion && highlighted)} ${right} ${pad(label, 8)}`;
440
462
 
441
463
  if (row.kind === "fusion") {
442
- const leadName = this.nameOf(this.lead, 14);
443
- const sideName = this.nameOf(this.sidekick, 14);
444
- const leadFocused = highlighted && this.focus === "lead";
445
- const sideFocused = highlighted && this.focus === "sidekick";
446
- const leadText = leadFocused
447
- ? `${t.fg("accent", t.bold("Lead"))} ${t.fg("accent", leadName)} ${t.fg("accent", "")}`
448
- : `${t.fg("dim", "Lead")} ${t.fg("text", leadName)} ${t.fg("dim", "")}`;
449
- const sideText = sideFocused
450
- ? `${t.fg("accent", t.bold("Sidekick"))} ${t.fg("accent", sideName)} ${t.fg("accent", "▾")}`
451
- : `${t.fg("dim", "Sidekick")} ${t.fg("text", sideName)} ${t.fg("dim", "▾")}`;
452
- line += ` ${leadText} ${sideText}`;
464
+ if (disabledFusion) {
465
+ line += ` ${t.fg("dim", "not configured — run /unipi:fusion-preset")}`;
466
+ } else {
467
+ const leadName = this.nameOf(this.lead, 14);
468
+ const sideName = this.nameOf(this.sidekick, 14);
469
+ const leadFocused = highlighted && this.focus === "lead";
470
+ const sideFocused = highlighted && this.focus === "sidekick";
471
+ const leadText = leadFocused
472
+ ? `${t.fg("accent", t.bold("Lead"))} ${t.fg("accent", leadName)} ${t.fg("accent", "▾")}`
473
+ : `${t.fg("dim", "Lead")} ${t.fg("text", leadName)} ${t.fg("dim", "▾")}`;
474
+ const sideText = sideFocused
475
+ ? `${t.fg("accent", t.bold("Sidekick"))} ${t.fg("accent", sideName)} ${t.fg("accent", "▾")}`
476
+ : `${t.fg("dim", "Sidekick")} ${t.fg("text", sideName)} ${t.fg("dim", "▾")}`;
477
+ line += ` ${leadText} ${sideText}`;
478
+ }
453
479
  }
454
480
  return truncateToWidth(line, Math.max(1, width - 1));
455
481
  }
@@ -478,6 +504,7 @@ export class ModelPicker {
478
504
  private renderPricePanel(row: Row | undefined, width: number): string[] {
479
505
  const t = this.theme;
480
506
  if (row === undefined) return [];
507
+ const disabledFusion = row.kind === "fusion" && !this.fusionAvailable();
481
508
  const primaryKey = row.kind === "fusion" ? this.lead : row.key;
482
509
  const primary = primaryKey === undefined ? undefined : this.modelsByKey.get(primaryKey);
483
510
  const side = row.kind === "fusion" && this.sidekick !== undefined ? this.modelsByKey.get(this.sidekick) : undefined;
@@ -505,18 +532,20 @@ export class ModelPicker {
505
532
  const head = cols.map(([h]) => pad(t.fg("dim", h), colWidth)).join("");
506
533
  const vals = cols.map(([, v]) => pad(t.fg("text", v), colWidth)).join("");
507
534
  const desc =
508
- row.kind === "fusion"
509
- ? t.fg("dim", "Pairs frontier intelligence with cost-efficient execution")
510
- : primary?.reasoning
511
- ? t.fg("dim", "Reasoning model · ←/→ adjusts thinking effort")
512
- : t.fg("dim", "Non-reasoning model · effort is ignored by the provider");
535
+ disabledFusion
536
+ ? t.fg("warning", "Run /unipi:fusion-preset to enable Fusion — a powerful lead model plans and reviews while a cheaper sidekick executes, for frontier performance at lower cost")
537
+ : row.kind === "fusion"
538
+ ? t.fg("dim", "Pairs frontier intelligence with cost-efficient execution")
539
+ : primary?.reasoning
540
+ ? t.fg("dim", "Reasoning model · ←/→ adjusts thinking effort")
541
+ : t.fg("dim", "Non-reasoning model · effort is ignored by the provider");
513
542
  const badges = this.state.models.some((m) => m.badge !== undefined)
514
543
  ? `${t.fg("success", "✱")} ${t.fg("dim", "New")} ${t.fg("accent", "✱")} ${t.fg("dim", "Promotion")} ${t.fg("warning", "✱")} ${t.fg("dim", "Beta")} ${t.fg("dim", "·")}`
515
544
  : "";
516
545
  const noPricing = row.kind === "fusion"
517
546
  ? !hasPricing(primaryCost) || !hasPricing(sideCost)
518
547
  : !hasPricing(primaryCost);
519
- const pricing = noPricing ? t.fg("dim", " · no pricing data from provider") : "";
548
+ const pricing = !disabledFusion && noPricing ? t.fg("dim", " · no pricing data from provider") : "";
520
549
  const description = `${badges}${badges.length > 0 ? " " : ""}${desc}${pricing}`;
521
550
  return [truncateToWidth(` ${head}`, width - 1), truncateToWidth(` ${vals}`, width - 1), truncateToWidth(` ${description}`, width - 1)];
522
551
  }
@@ -524,7 +553,9 @@ export class ModelPicker {
524
553
  private hintLine(row: Row | undefined): string {
525
554
  const t = this.theme;
526
555
  const parts: string[] = [];
527
- if (row?.kind === "fusion" && this.focus !== "effort") {
556
+ if (row?.kind === "fusion" && !this.fusionAvailable()) {
557
+ parts.push("↑↓ select", "esc cancel");
558
+ } else if (row?.kind === "fusion" && this.focus !== "effort") {
528
559
  parts.push("↑↓ select", `tab ${this.focus === "lead" ? "sidekick" : "effort"}`, "↵ apply", "esc collapse");
529
560
  } else {
530
561
  parts.push("↑↓ select");