cue-ai 0.9.0 → 0.9.2

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 (310) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +82 -33
  3. package/bin/cue-review-progress +107 -0
  4. package/bin/cue-review-watch +98 -0
  5. package/dist/cue.js +7352 -3744
  6. package/package.json +16 -5
  7. package/profiles/_types.ts +9 -0
  8. package/profiles/backend/profile.yaml +2 -0
  9. package/profiles/blog-writer/profile.yaml +10 -0
  10. package/profiles/browser/profile.yaml +9 -2
  11. package/profiles/builder/profile.yaml +3 -6
  12. package/profiles/career/profile.yaml +13 -2
  13. package/profiles/claude-api/profile.yaml +1 -1
  14. package/profiles/commerce/profile.yaml +27 -3
  15. package/profiles/core/logo.png +0 -0
  16. package/profiles/core/profile.yaml +62 -2
  17. package/profiles/dash-merge-test/profile.yaml +109 -0
  18. package/profiles/designer/profile.yaml +2 -0
  19. package/profiles/designer-medusa-next/profile.yaml +4 -1
  20. package/profiles/designer-medusa-vite/profile.yaml +4 -1
  21. package/profiles/docs-writer/profile.yaml +3 -1
  22. package/profiles/eu-tender-research/README.md +48 -0
  23. package/profiles/eu-tender-research/logo.png +0 -0
  24. package/profiles/eu-tender-research/profile.yaml +108 -0
  25. package/profiles/finance/logo.png +0 -0
  26. package/profiles/finance/profile.yaml +46 -0
  27. package/profiles/frontend/profile.yaml +5 -9
  28. package/profiles/growth/profile.yaml +2 -3
  29. package/profiles/gstack/profile.yaml +15 -0
  30. package/profiles/higgsfield/profile.yaml +3 -0
  31. package/profiles/hyperframes/logo.png +0 -0
  32. package/profiles/hyperframes/profile.yaml +59 -0
  33. package/profiles/improver/profile.yaml +88 -0
  34. package/profiles/marketing/profile.yaml +0 -3
  35. package/profiles/medusa-dev/profile.yaml +2 -0
  36. package/profiles/medusa-next/profile.yaml +2 -3
  37. package/profiles/medusa-vite/profile.yaml +2 -3
  38. package/profiles/n8n/logo.png +0 -0
  39. package/profiles/n8n/profile.yaml +50 -0
  40. package/profiles/nextjs/profile.yaml +2 -3
  41. package/profiles/ops/profile.yaml +2 -0
  42. package/profiles/postizz/profile.yaml +13 -3
  43. package/profiles/python/profile.yaml +3 -0
  44. package/profiles/research/profile.yaml +3 -1
  45. package/profiles/schema.json +10 -0
  46. package/profiles/secops/profile.yaml +2 -0
  47. package/profiles/seo/profile.yaml +56 -0
  48. package/profiles/skill-writer/profile.yaml +8 -0
  49. package/profiles/ssh/profile.yaml +32 -0
  50. package/profiles/strapi/logo.png +0 -0
  51. package/profiles/strapi/profile.yaml +45 -0
  52. package/profiles/stripe/logo.png +0 -0
  53. package/profiles/stripe/profile.yaml +1 -0
  54. package/profiles/supabase/logo.png +0 -0
  55. package/profiles/supabase/profile.yaml +85 -0
  56. package/profiles/vercel/logo.png +0 -0
  57. package/profiles/vercel/profile.yaml +25 -1
  58. package/profiles/vite/profile.yaml +4 -3
  59. package/profiles/web-frontend-base/profile.yaml +5 -4
  60. package/profiles/webshop/profile.yaml +23 -5
  61. package/profiles/x-growth-bot/profile.yaml +44 -0
  62. package/resources/icons/generate-icons.py +128 -2
  63. package/resources/mcps/configs/claude.sanitized.json +42 -0
  64. package/resources/mcps/configs/codex.sanitized.json +7 -0
  65. package/resources/skills/skills/career/resume-version-manager/SKILL.md +351 -0
  66. package/resources/skills/skills/career/salary-negotiation-prep/SKILL.md +378 -0
  67. package/resources/skills/skills/content/pdf/SKILL.md +2 -0
  68. package/resources/skills/skills/content/postiz-cards/SKILL.md +48 -0
  69. package/resources/skills/skills/content/postiz-cards/scripts/analytics.sh +38 -0
  70. package/resources/skills/skills/content/postiz-cards/scripts/card.sh +42 -0
  71. package/resources/skills/skills/content/postiz-cards/scripts/lint.py +38 -0
  72. package/resources/skills/skills/design/headless-gif-demo/SKILL.md +1 -1
  73. package/resources/skills/skills/design/readme-svg-design/SKILL.md +1 -1
  74. package/resources/skills/skills/eu-funding/grant-outreach/SKILL.md +70 -0
  75. package/resources/skills/skills/eu-funding/hu-grant-finder/SKILL.md +114 -0
  76. package/resources/skills/skills/eu-funding/hu-grant-finder/evals.md +26 -0
  77. package/resources/skills/skills/eu-funding/ted-tender-search/SKILL.md +80 -0
  78. package/resources/skills/skills/eu-funding/ted-tender-search/evals.md +26 -0
  79. package/resources/skills/skills/eu-funding/ted-tender-search/scripts/ted-search.sh +46 -0
  80. package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +1 -1
  81. package/resources/skills/skills/github/gx-agents/SKILL.md +96 -0
  82. package/resources/skills/skills/gstack/design-shotgun/SKILL.md +1 -1
  83. package/resources/skills/skills/marketing/ab-test-analyzer/SKILL.md +1 -1
  84. package/resources/skills/skills/marketing/ab-test-setup-and-analysis/SKILL.md +1 -1
  85. package/resources/skills/skills/marketing/account-structure-review/SKILL.md +1 -1
  86. package/resources/skills/skills/marketing/ad-copy-variant-generator/SKILL.md +1 -1
  87. package/resources/skills/skills/marketing/ad-extension-audit/SKILL.md +1 -1
  88. package/resources/skills/skills/marketing/ad-spend-allocator/SKILL.md +1 -1
  89. package/resources/skills/skills/marketing/anomaly-detection/SKILL.md +1 -1
  90. package/resources/skills/skills/marketing/attribution-model-comparison/SKILL.md +1 -1
  91. package/resources/skills/skills/marketing/audience-overlap-analysis/SKILL.md +7 -1
  92. package/resources/skills/skills/marketing/bid-strategy-recommendations/SKILL.md +7 -1
  93. package/resources/skills/skills/marketing/budget-scenario-planner/SKILL.md +6 -1
  94. package/resources/skills/skills/marketing/campaign-naming-convention-builder/SKILL.md +7 -1
  95. package/resources/skills/skills/marketing/channel-mix-optimizer/SKILL.md +7 -1
  96. package/resources/skills/skills/marketing/client-report-narratives/SKILL.md +6 -1
  97. package/resources/skills/skills/marketing/competitor-creative-analysis/SKILL.md +1 -1
  98. package/resources/skills/skills/marketing/competitor-teardown/SKILL.md +1 -1
  99. package/resources/skills/skills/marketing/content-repurposer/SKILL.md +1 -1
  100. package/resources/skills/skills/marketing/conversion-path-analysis/SKILL.md +1 -1
  101. package/resources/skills/skills/marketing/cpa-diagnostics/SKILL.md +1 -1
  102. package/resources/skills/skills/marketing/creative-fatigue-detection/SKILL.md +1 -1
  103. package/resources/skills/skills/marketing/day-hour-performance-breakdown/SKILL.md +1 -1
  104. package/resources/skills/skills/marketing/device-performance-split/SKILL.md +1 -1
  105. package/resources/skills/skills/marketing/e2e-seo-assistant/SKILL.md +1 -1
  106. package/resources/skills/skills/marketing/email-sequence-writer/SKILL.md +1 -1
  107. package/resources/skills/skills/marketing/frequency-cap-recommendations/SKILL.md +1 -1
  108. package/resources/skills/skills/marketing/geo-performance-analysis/SKILL.md +1 -1
  109. package/resources/skills/skills/marketing/google-ads-audit/SKILL.md +1 -1
  110. package/resources/skills/skills/marketing/icp-research-assistant/SKILL.md +1 -1
  111. package/resources/skills/skills/marketing/keyword-cannibalization-check/SKILL.md +1 -1
  112. package/resources/skills/skills/marketing/landing-page-audit/SKILL.md +1 -1
  113. package/resources/skills/skills/marketing/landing-page-audit-quick/SKILL.md +1 -1
  114. package/resources/skills/skills/marketing/linkedin-ads-audit/SKILL.md +1 -1
  115. package/resources/skills/skills/marketing/meta-ads-audit/SKILL.md +1 -1
  116. package/resources/skills/skills/marketing/pacing-monitor/SKILL.md +1 -1
  117. package/resources/skills/skills/marketing/performance-benchmarking/SKILL.md +1 -1
  118. package/resources/skills/skills/marketing/programmatic-seo-builder/SKILL.md +1 -1
  119. package/resources/skills/skills/marketing/quality-score-breakdown/SKILL.md +1 -1
  120. package/resources/skills/skills/marketing/reddit-ads-audit/SKILL.md +1 -1
  121. package/resources/skills/skills/marketing/retargeting-window-analysis/SKILL.md +1 -1
  122. package/resources/skills/skills/marketing/roas-forecasting/SKILL.md +1 -1
  123. package/resources/skills/skills/marketing/search-term-mining/SKILL.md +1 -1
  124. package/resources/skills/skills/marketing/utm-tracking-generator/SKILL.md +1 -1
  125. package/resources/skills/skills/marketing/wasted-spend-finder/SKILL.md +1 -1
  126. package/resources/skills/skills/marketing/weekly-account-summary/SKILL.md +1 -1
  127. package/resources/skills/skills/meta/awesome-list-submit/SKILL.md +4 -4
  128. package/resources/skills/skills/meta/cue-dashboard/SKILL.md +109 -0
  129. package/resources/skills/skills/meta/cue-developer/SKILL.md +161 -0
  130. package/resources/skills/skills/meta/cue-developer/evals/evals.json +57 -0
  131. package/resources/skills/skills/meta/cue-developer/references/architecture.md +65 -0
  132. package/resources/skills/skills/meta/cue-developer/references/build_and_test.md +72 -0
  133. package/resources/skills/skills/meta/cue-developer/references/contributing.md +75 -0
  134. package/resources/skills/skills/meta/cue-developer/references/conventions.md +57 -0
  135. package/resources/skills/skills/meta/cue-developer/references/first_time_setup.md +51 -0
  136. package/resources/skills/skills/meta/cue-developer/references/skill_and_mcp_authoring.md +84 -0
  137. package/resources/skills/skills/meta/cue-developer/references/troubleshooting.md +42 -0
  138. package/resources/skills/skills/meta/delegation-check/SKILL.md +148 -0
  139. package/resources/skills/skills/meta/delegation-check/specs/scan-algorithm.md +125 -0
  140. package/resources/skills/skills/meta/delegation-check/specs/separation-rules.md +190 -0
  141. package/resources/skills/skills/meta/focus/SKILL.md +62 -0
  142. package/resources/skills/skills/meta/help/SKILL.md +1 -1
  143. package/resources/skills/skills/meta/integrity-tags/SKILL.md +2 -0
  144. package/resources/skills/skills/meta/next-steps/SKILL.md +124 -0
  145. package/resources/skills/skills/meta/next-steps/evals/eval-set.json +92 -0
  146. package/resources/skills/skills/meta/profile-from-docs/SKILL.md +141 -0
  147. package/resources/skills/skills/meta/ralph-loop/SKILL.md +83 -0
  148. package/resources/skills/skills/meta/ralph-loop/scripts/loop.sh +73 -0
  149. package/resources/skills/skills/meta/skill-simplify/SKILL.md +136 -0
  150. package/resources/skills/skills/meta/skill-simplify/phases/01-analysis.md +173 -0
  151. package/resources/skills/skills/meta/skill-simplify/phases/02-optimize.md +104 -0
  152. package/resources/skills/skills/meta/skill-simplify/phases/03-check.md +145 -0
  153. package/resources/skills/skills/meta/smart-loader/scripts/smart-lookup.sh +13 -4
  154. package/resources/skills/skills/meta/verify-council/SKILL.md +182 -0
  155. package/resources/skills/skills/meta/verify-council/references/lane-prompts.md +103 -0
  156. package/resources/skills/skills/meta/verify-council/references/workflow.js +217 -0
  157. package/resources/skills/skills/nvidia/aiq-research/SKILL.md +1 -1
  158. package/resources/skills/skills/nvidia/cuopt-developer/SKILL.md +16 -1
  159. package/resources/skills/skills/nvidia/cuopt-developer/resources/contributing.md +2 -2
  160. package/resources/skills/skills/nvidia/cuopt-developer/resources/numerical_debugging.md +128 -0
  161. package/resources/skills/skills/nvidia/cuopt-developer/resources/python_bindings.md +2 -9
  162. package/resources/skills/skills/nvidia/cuopt-developer/resources/vrp_skills.md +166 -0
  163. package/resources/skills/skills/nvidia/cuopt-install/SKILL.md +2 -10
  164. package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-c/SKILL.md +3 -23
  165. package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-c/resources/examples.md +40 -20
  166. package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-python/SKILL.md +5 -1
  167. package/resources/skills/skills/nvidia/skill-evolution/SKILL.md +4 -5
  168. package/resources/skills/skills/research/trendradar/SKILL.md +1 -1
  169. package/resources/skills/skills/ssh/ssh-config/SKILL.md +94 -0
  170. package/resources/skills/skills/ssh/ssh-copy/SKILL.md +92 -0
  171. package/resources/skills/skills/ssh/ssh-harden/SKILL.md +108 -0
  172. package/resources/skills/skills/ssh/ssh-keys/SKILL.md +82 -0
  173. package/resources/skills/skills/ssh/ssh-paste-image/LICENSE +28 -0
  174. package/resources/skills/skills/ssh/ssh-paste-image/SKILL.md +149 -0
  175. package/resources/skills/skills/ssh/ssh-paste-image/scripts/build.sh +29 -0
  176. package/resources/skills/skills/ssh/ssh-paste-image/scripts/client/go.mod +3 -0
  177. package/resources/skills/skills/ssh/ssh-paste-image/scripts/client/main.go +79 -0
  178. package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/ccimgd.service +12 -0
  179. package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/com.ccimgd.plist +20 -0
  180. package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/go.mod +3 -0
  181. package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/main.go +98 -0
  182. package/resources/skills/skills/ssh/ssh-tunnel/SKILL.md +96 -0
  183. package/resources/skills/skills/strapi/building-with-strapi/SKILL.md +112 -0
  184. package/resources/skills/skills/strapi/strapi-cli/SKILL.md +93 -0
  185. package/resources/skills/skills/strapi/strapi-content-api/SKILL.md +115 -0
  186. package/resources/skills/skills/strapi/strapi-deploy/SKILL.md +89 -0
  187. package/resources/skills/skills/strapi/strapi-mcp-setup/SKILL.md +101 -0
  188. package/resources/skills/skills/strapi/strapi-plugins/SKILL.md +97 -0
  189. package/resources/skills/skills/tools/context7/SKILL.md +101 -0
  190. package/resources/skills/skills/tools/opensrc/SKILL.md +1 -1
  191. package/resources/skills/skills/tools/portless/SKILL.md +186 -0
  192. package/resources/skills/skills/xbot/operate/SKILL.md +229 -0
  193. package/src/commands/_index.ts +8 -0
  194. package/src/commands/ai-score.e2e.test.ts +11 -4
  195. package/src/commands/ai.ts +3 -4
  196. package/src/commands/auto-detect.ts +1 -1
  197. package/src/commands/cli.test.ts +1 -2
  198. package/src/commands/cli.ts +1 -1
  199. package/src/commands/cloud.ts +1 -1
  200. package/src/commands/current.ts +1 -4
  201. package/src/commands/dash.test.ts +110 -0
  202. package/src/commands/dash.ts +194 -0
  203. package/src/commands/dashboard.ts +26 -0
  204. package/src/commands/diff.ts +1 -1
  205. package/src/commands/discover.test.ts +1 -1
  206. package/src/commands/discover.ts +90 -40
  207. package/src/commands/doctor.test.ts +58 -0
  208. package/src/commands/doctor.ts +79 -3
  209. package/src/commands/eval-behavior.ts +1 -1
  210. package/src/commands/eval.ts +2 -2
  211. package/src/commands/evolve.ts +4 -3
  212. package/src/commands/failures.test.ts +1 -1
  213. package/src/commands/features-batch1.test.ts +6 -1
  214. package/src/commands/icon.ts +1 -5
  215. package/src/commands/import-profile.ts +1 -1
  216. package/src/commands/init.ts +50 -7
  217. package/src/commands/install-sh.e2e.test.ts +65 -0
  218. package/src/commands/launch-handoff.e2e.test.ts +88 -0
  219. package/src/commands/launch.e2e.test.ts +8 -1
  220. package/src/commands/launch.test.ts +29 -0
  221. package/src/commands/launch.ts +185 -131
  222. package/src/commands/lock.ts +0 -1
  223. package/src/commands/marketplace.ts +0 -4
  224. package/src/commands/materialize.ts +1 -1
  225. package/src/commands/mem.ts +341 -0
  226. package/src/commands/optimizer.ts +0 -3
  227. package/src/commands/playground.ts +1 -2
  228. package/src/commands/profile-draft-skill.ts +1 -1
  229. package/src/commands/replay-whatif.ts +1 -6
  230. package/src/commands/score.ts +2 -2
  231. package/src/commands/security.test.ts +88 -0
  232. package/src/commands/security.ts +74 -28
  233. package/src/commands/shell.test.ts +65 -4
  234. package/src/commands/shell.ts +67 -7
  235. package/src/commands/skills-test.ts +0 -1
  236. package/src/commands/skills.ts +28 -2
  237. package/src/commands/sources.ts +1 -2
  238. package/src/commands/status.ts +2 -6
  239. package/src/commands/submit-profile.ts +1 -1
  240. package/src/commands/suggest.ts +35 -10
  241. package/src/commands/trigger-gaps.test.ts +50 -0
  242. package/src/commands/trigger-gaps.ts +63 -29
  243. package/src/commands/update.ts +1 -1
  244. package/src/commands/validate.ts +16 -4
  245. package/src/commands/watch-live.ts +1 -1
  246. package/src/commands/workspace.ts +1 -1
  247. package/src/index.ts +26 -10
  248. package/src/lib/active-sessions.ts +1 -1
  249. package/src/lib/agent-adapters.test.ts +100 -0
  250. package/src/lib/agent-adapters.ts +2 -2
  251. package/src/lib/analytics.test.ts +88 -0
  252. package/src/lib/analytics.ts +82 -1
  253. package/src/lib/auto-detect.test.ts +10 -4
  254. package/src/lib/auto-detect.ts +19 -23
  255. package/src/lib/brand-icons.ts +0 -1
  256. package/src/lib/cache.ts +2 -3
  257. package/src/lib/claude-mem-env.test.ts +148 -0
  258. package/src/lib/claude-mem-env.ts +172 -0
  259. package/src/lib/combo-history.test.ts +53 -0
  260. package/src/lib/combo-history.ts +83 -0
  261. package/src/lib/companion-detect.test.ts +108 -0
  262. package/src/lib/companion-detect.ts +140 -0
  263. package/src/lib/companion-fetch.ts +4 -6
  264. package/src/lib/conditional-skills.test.ts +1 -1
  265. package/src/lib/config-paths.test.ts +53 -0
  266. package/src/lib/config-paths.ts +33 -0
  267. package/src/lib/dashboard-server.test.ts +351 -0
  268. package/src/lib/dashboard-server.ts +1476 -27
  269. package/src/lib/debug-log.test.ts +66 -0
  270. package/src/lib/debug-log.ts +45 -0
  271. package/src/lib/mcp-catalog.test.ts +102 -0
  272. package/src/lib/mcp-catalog.ts +193 -0
  273. package/src/lib/pair-suggestions.test.ts +111 -0
  274. package/src/lib/pair-suggestions.ts +98 -5
  275. package/src/lib/permissions.test.ts +76 -0
  276. package/src/lib/permissions.ts +125 -0
  277. package/src/lib/picker.test.ts +1106 -1
  278. package/src/lib/picker.ts +1230 -142
  279. package/src/lib/plugin-discovery.ts +126 -0
  280. package/src/lib/pr-poster.ts +1 -1
  281. package/src/lib/pr-throttle.ts +2 -6
  282. package/src/lib/profile-linter.test.ts +67 -1
  283. package/src/lib/profile-linter.ts +59 -14
  284. package/src/lib/profile-loader.test.ts +21 -0
  285. package/src/lib/profile-loader.ts +22 -3
  286. package/src/lib/profile-metrics.ts +2 -6
  287. package/src/lib/profile-names.test.ts +58 -0
  288. package/src/lib/repos.test.ts +57 -0
  289. package/src/lib/repos.ts +167 -0
  290. package/src/lib/resolver-npx.ts +10 -1
  291. package/src/lib/runtime-materializer.test.ts +200 -3
  292. package/src/lib/runtime-materializer.ts +129 -20
  293. package/src/lib/shared-profiles.ts +2 -3
  294. package/src/lib/skill-clis.test.ts +113 -0
  295. package/src/lib/skill-clis.ts +232 -0
  296. package/src/lib/skill-dependencies.ts +9 -1
  297. package/src/lib/skill-deps.ts +1 -1
  298. package/src/lib/skill-linter.ts +1 -1
  299. package/src/lib/skill-quality.ts +0 -1
  300. package/src/lib/skill-sandbox.test.ts +1 -1
  301. package/src/lib/skills-lock.test.ts +1 -1
  302. package/src/lib/telemetry-consent.ts +3 -5
  303. package/src/lib/telemetry-report.test.ts +2 -2
  304. package/src/lib/token-budget.ts +111 -0
  305. package/src/lib/trigger-gaps.test.ts +70 -0
  306. package/src/lib/trigger-gaps.ts +48 -6
  307. package/src/lib/tui/data.ts +1 -5
  308. package/src/lib/workflow-store.ts +150 -0
  309. package/src/lib/workspace-secrets.ts +0 -4
  310. package/src/lib/workspaces.ts +1 -1
package/src/lib/picker.ts CHANGED
@@ -10,10 +10,14 @@
10
10
  */
11
11
 
12
12
  import * as p from "@clack/prompts";
13
- import { MultiSelectPrompt } from "@clack/core";
13
+ import { MultiSelectPrompt, Prompt, type PromptOptions } from "@clack/core";
14
14
  import { writeFile } from "node:fs/promises";
15
15
  import { join } from "node:path";
16
16
  import { styleText } from "node:util";
17
+ import type { CompanionSignal } from "./companion-detect";
18
+ import { tokenLevelEmoji } from "./token-budget";
19
+ import type { UniversalSuggestion, UniversalOrigin } from "./pair-suggestions";
20
+ import { recordCombo } from "./combo-history";
17
21
 
18
22
  export interface PickerOption {
19
23
  value: string;
@@ -29,6 +33,12 @@ export interface PickerOption {
29
33
  * real options in the same list are offered.
30
34
  */
31
35
  recommends?: string[];
36
+ /**
37
+ * Companion profile `value`s that start CHECKED when this option is the
38
+ * combine primary, regardless of cwd detection (the profile's `autoSelect:`).
39
+ * Stronger than `recommends`; still opt-out (the user can uncheck).
40
+ */
41
+ autoSelect?: string[];
32
42
  /**
33
43
  * Other profile `value`s that are mutually exclusive with this one. In the
34
44
  * combine multiselect, checking this option auto-disables every conflict
@@ -101,6 +111,33 @@ export interface PickerInput {
101
111
  * lets the post-pick nudge cite the reason that triggered the conflict.
102
112
  */
103
113
  detected?: ReadonlyArray<{ name: string; reasons: string[]; confidence: number }>;
114
+ /**
115
+ * Content-detected combine companions (see `lib/companion-detect`). Flat and
116
+ * primary-independent: signals come from the cwd's contents (image/video
117
+ * assets → higgsfield, markdown drafts → blog-writer, a registered brand
118
+ * folder → postizz), not from which profile the user picks. Folded into the
119
+ * combine multiselect alongside `recommends`/`pairSuggestions`; high-
120
+ * confidence entries start checked. `buildCompanionOptions` drops any that
121
+ * equal — or conflict with — the picked primary.
122
+ */
123
+ companions?: CompanionSignal[];
124
+ /**
125
+ * Cross-profile combine suggestions offered under *every* primary: the curated
126
+ * featured set (where profiles like `improver` live) plus the user's
127
+ * most-frequently-picked profiles, mined from session history (see
128
+ * `buildUniversalSuggestions`). Folded into the combine multiselect after
129
+ * recommends/history/detected, de-duped, and surfaced *unchecked* — a hint,
130
+ * never an auto-pin. Empty/missing = recommends + universal-companion only.
131
+ */
132
+ universalSuggestions?: UniversalSuggestion[];
133
+ /**
134
+ * Optional resolver for a single profile value's own resources, used to drive
135
+ * the combine multiselect's per-row "+N skills" hints and the live combined-
136
+ * total preview. Called once per offered profile (primary + companions)
137
+ * before the multiselect opens; failures degrade gracefully (that row simply
138
+ * shows no counts). Omitted in tests → no preview, identical prior behavior.
139
+ */
140
+ resourceTally?: (profileValue: string) => Promise<ProfileTally> | ProfileTally;
104
141
  }
105
142
 
106
143
  export interface PickerOutput {
@@ -112,7 +149,7 @@ export interface PickerOutput {
112
149
  // which render as blanks in some fonts under kitty/tmux — the user can't see
113
150
  // what's on or off. This wraps @clack/core's MultiSelectPrompt with an ASCII
114
151
  // render so the state is visible everywhere.
115
- type AsciiMSOption = {
152
+ export type AsciiMSOption = {
116
153
  value: string;
117
154
  label: string;
118
155
  hint?: string;
@@ -121,16 +158,97 @@ type AsciiMSOption = {
121
158
  * the final result. Symmetric — a one-sided declaration blocks both. */
122
159
  conflicts?: string[];
123
160
  /** "action" rows (e.g. the skip-combine escape hatch) render distinct: no
124
- * checkbox, a dim divider above, dim glyph when unselected. */
125
- kind?: "action";
161
+ * checkbox, a dim divider above, dim glyph when unselected. "expand" is the
162
+ * one-shot "show all profiles" row: rendered pinned *below* the companion
163
+ * window, toggling it reveals the overflow list (see `asciiMultiselect`). */
164
+ kind?: "action" | "expand";
165
+ /** Primary profile's label, carried on the skip-combine action row so the
166
+ * live render can rebuild its text ("use X alone" ↔ "use X + Y") from the
167
+ * current selection instead of the static `label`. */
168
+ primaryLabel?: string;
169
+ /** How many profiles the "show all" expand row reveals — drives its label
170
+ * ("show all 40 profiles ↓"). Only set on the `kind:"expand"` row. */
171
+ expandCount?: number;
172
+ /** Set on the primary's `recommends:` companions. Renders a `→` gutter marker
173
+ * (when not the cursor) and a dim "recommended" tag so the suggested pairing
174
+ * stands out from history/detected/overflow rows. */
175
+ recommended?: boolean;
176
+ /** Category bucket for the grouped combine view (see `combineCategoryOf`).
177
+ * Drives the category headers rendered between groups in `renderCombineFrame`. */
178
+ category?: string;
179
+ /** Render a danger marker (⚠ + label) — e.g. `full` profile "never use this". */
180
+ danger?: string;
181
+ };
182
+
183
+ // Profile categories for the grouped combine list (from the cue-combine design):
184
+ // turn the flat 40-item wall into scannable groups. Names not listed fall into
185
+ // "other", which sorts last so the catalogue still shows everything.
186
+ export const COMBINE_CATEGORY_ORDER = [
187
+ "orchestrators",
188
+ "content & research",
189
+ "frontend & design",
190
+ "backend & infra",
191
+ "commerce",
192
+ "integrations",
193
+ "other",
194
+ ] as const;
195
+
196
+ const COMBINE_CATEGORY_OF: Record<string, string> = {
197
+ growth: "orchestrators", builder: "orchestrators", studio: "orchestrators",
198
+ maker: "orchestrators", improver: "orchestrators", agency: "orchestrators", full: "orchestrators",
199
+ "blog-writer": "content & research", "docs-writer": "content & research",
200
+ marketing: "content & research", research: "content & research", "readme-writer": "content & research",
201
+ frontend: "frontend & design", nextjs: "frontend & design", vite: "frontend & design",
202
+ "react-native": "frontend & design", designer: "frontend & design",
203
+ "designer-medusa-next": "frontend & design", "designer-medusa-vite": "frontend & design",
204
+ threejs: "frontend & design", browser: "frontend & design", wordpress: "frontend & design",
205
+ "web-frontend-base": "frontend & design", "creative-media": "frontend & design", "event-design": "frontend & design",
206
+ backend: "backend & infra", postgres: "backend & infra", supabase: "backend & infra",
207
+ strapi: "backend & infra", aws: "backend & infra", vercel: "backend & infra",
208
+ resend: "backend & infra", secops: "backend & infra", ops: "backend & infra",
209
+ coolify: "backend & infra", hostinger: "backend & infra", "backend-base": "backend & infra",
210
+ python: "backend & infra", "go-api": "backend & infra", rust: "backend & infra", "rust-core": "backend & infra",
211
+ cybersecurity: "backend & infra",
212
+ commerce: "commerce", webshop: "commerce", "webshop-google": "commerce", stripe: "commerce",
213
+ finance: "commerce", "medusa-stack": "commerce", "medusa-dev": "commerce",
214
+ "medusa-next": "commerce", "medusa-vite": "commerce",
215
+ slack: "integrations", linear: "integrations", "claude-api": "integrations", ssh: "integrations",
216
+ video: "integrations", postizz: "integrations", higgsfield: "integrations",
217
+ "google-ads": "integrations", "google-analytics": "integrations", "google-drive": "integrations",
218
+ instagram: "integrations", nvidia: "integrations",
126
219
  };
127
220
 
221
+ /** Bucket a profile into a combine category. Unknown names → "other" (sorts last). */
222
+ export function combineCategoryOf(name: string): string {
223
+ return COMBINE_CATEGORY_OF[name] ?? "other";
224
+ }
225
+
226
+ /** Stable sort an option list into category order (see COMBINE_CATEGORY_ORDER),
227
+ * preserving the incoming order within each category. Tags each option's
228
+ * `category` so the renderer can emit group headers. */
229
+ export function groupByCategory(opts: AsciiMSOption[]): AsciiMSOption[] {
230
+ const order = (c: string) => {
231
+ const i = COMBINE_CATEGORY_ORDER.indexOf(c as (typeof COMBINE_CATEGORY_ORDER)[number]);
232
+ return i < 0 ? COMBINE_CATEGORY_ORDER.length : i;
233
+ };
234
+ // Keep control rows pinned — the skip-combine action leads, the show-all
235
+ // expand row trails — and only group the actual profile rows by category.
236
+ const lead = opts.filter((o) => o.kind === "action");
237
+ const trail = opts.filter((o) => o.kind === "expand");
238
+ const middle = opts
239
+ .filter((o) => o.kind !== "action" && o.kind !== "expand")
240
+ .map((o, i) => ({ o: { ...o, category: combineCategoryOf(o.value) }, i }))
241
+ .sort((a, b) => order(a.o.category!) - order(b.o.category!) || a.i - b.i)
242
+ .map((x) => x.o);
243
+ return [...lead, ...middle, ...trail];
244
+ }
245
+
128
246
  /**
129
247
  * Build a symmetric conflict map from a list of options. Declaring `A.conflicts
130
248
  * = [B]` on either side blocks both A→B and B→A so authors only have to write
131
249
  * the relationship once.
132
250
  */
133
- function buildConflictMap(options: AsciiMSOption[]): Map<string, Set<string>> {
251
+ export function buildConflictMap(options: AsciiMSOption[]): Map<string, Set<string>> {
134
252
  const map = new Map<string, Set<string>>();
135
253
  for (const o of options) {
136
254
  for (const c of o.conflicts ?? []) {
@@ -161,106 +279,1057 @@ export function resolveConflicts(
161
279
  return out;
162
280
  }
163
281
 
282
+ /** Sentinel for the combine multiselect's "use <primary> alone" escape hatch. */
283
+ export const SKIP_COMBINE = "__skip_combine__";
284
+
285
+ /**
286
+ * Sentinel for the "show all profiles" expand row. Toggling it reveals every
287
+ * non-curated profile as a combine companion (one-way). Stripped from the
288
+ * returned selection like `SKIP_COMBINE` — it's a control, not a profile.
289
+ */
290
+ export const SHOW_ALL = "__show_all__";
291
+
292
+ // Always-on combine companions (gstack) now flow through the single
293
+ // `buildUniversalSuggestions` path as the `pinned` origin — re-exported here so
294
+ // existing `import { UNIVERSAL_COMPANIONS } from "./picker"` call sites keep
295
+ // resolving. The canonical definition lives in `./pair-suggestions`.
296
+ export { UNIVERSAL_COMPANIONS } from "./pair-suggestions";
297
+
298
+ /** Hint shown on a row surfaced purely because it's a `_featured.yaml` pick. */
299
+ export const FEATURED_HINT = "featured";
300
+ /** Hint shown on a row surfaced purely from session pick-frequency. */
301
+ export const FREQUENT_HINT = "you use often";
302
+ /** Hint shown on a row surfaced purely as a `UNIVERSAL_COMPANIONS` pick. */
303
+ export const UNIVERSAL_HINT = "pairs with any stack";
304
+ /** Hint shown on a row surfaced from a previously-picked combo (combo-history).
305
+ * These are offered *unchecked* — a recommendation, never an auto-pin. */
306
+ export const HISTORY_HINT = "you paired these before";
307
+
308
+ /**
309
+ * Confidence at/above which a content-detected companion starts checked in the
310
+ * combine multiselect. Mirrors launch.ts's `SUGGESTED_AUTO_PICK_CONFIDENCE`
311
+ * (kept as a local const so this lower-level module has no upward dependency).
312
+ */
313
+ export const COMBINE_AUTO_CHECK_CONFIDENCE = 0.7;
314
+
315
+ /**
316
+ * How many "you use often" (frequent-origin) rows start checked. Recents are
317
+ * opt-out, but auto-ticking *every* frequent profile is what balloons a default
318
+ * stack into a 20K-always-on monster — so only the top few (the list arrives
319
+ * frequency-desc) start checked; the rest are offered unchecked.
320
+ */
321
+ export const MAX_FREQUENT_AUTOCHECK = 3;
322
+
323
+ export interface BuildCompanionArgs {
324
+ /** The picked primary profile. */
325
+ primary: string;
326
+ /** Display label for the "use <primary> alone" row. */
327
+ primaryLabel: string;
328
+ /** Full picker option list — source of each candidate's label/hint/conflicts. */
329
+ options: PickerOption[];
330
+ /** The primary's `recommends:` names. */
331
+ recommends: string[];
332
+ /** The primary's `autoSelect:` names — start checked, regardless of cwd. */
333
+ autoSelect: string[];
334
+ /** Historical partners for the primary (from session-log pair mining). */
335
+ pairSuggested: string[];
336
+ /** Content-detected companions for the cwd. */
337
+ companions: CompanionSignal[];
338
+ /** Confidence at/above which a detected companion starts checked. */
339
+ autoCheckThreshold: number;
340
+ /**
341
+ * Featured + frequently-used cross-profile suggestions (see
342
+ * `buildUniversalSuggestions`). Offered unchecked; a `featured`/`frequent`
343
+ * origin only drives the row hint when the profile isn't already a
344
+ * recommend/history/detected candidate.
345
+ */
346
+ universalSuggestions?: UniversalSuggestion[];
347
+ }
348
+
349
+ /**
350
+ * Assemble the combine multiselect's rows + which start checked.
351
+ *
352
+ * Candidates = the primary's `recommends:` ∪ historical pairings ∪ content-
353
+ * detected companions ∪ featured/frequently-used profiles ∪
354
+ * `UNIVERSAL_COMPANIONS` (offered under every primary), de-duped by profile
355
+ * (that order). A candidate is
356
+ * dropped when it is the primary itself, a profile that conflicts with the
357
+ * primary (either side of the declaration), a divider, a composite (`+`)
358
+ * value, or not a real option. A detected candidate shows its reason as the
359
+ * row hint (e.g. "12 image assets"). `initialValues` (start-checked) =
360
+ * historical partners ∪ `preselect`-flagged options ∪ detected companions at/
361
+ * above `autoCheckThreshold`. The trailing "use <primary> alone" action row is
362
+ * appended only when at least one real companion survives.
363
+ *
364
+ * Pure: no I/O, no TTY. The live multiselect just renders the result.
365
+ */
366
+ export function buildCompanionOptions(args: BuildCompanionArgs): {
367
+ companionOptions: AsciiMSOption[];
368
+ initialValues: string[];
369
+ /**
370
+ * Every selectable profile *not* already surfaced as a curated companion (and
371
+ * not the primary, a conflict, a divider, a composite, or the Default entry).
372
+ * Hidden until the user toggles the "show all profiles" expand row, at which
373
+ * point `asciiMultiselect` appends these to the live option list. Lets the
374
+ * user combine with any installed profile, not just the recommended set.
375
+ */
376
+ overflowOptions: AsciiMSOption[];
377
+ } {
378
+ const { primary, primaryLabel, options, recommends, pairSuggested, companions } = args;
379
+ const autoSelect = args.autoSelect ?? [];
380
+ const universalSuggestions = args.universalSuggestions ?? [];
381
+ const firstOpt = options.find((o) => o.value === primary);
382
+ const primaryConflicts = new Set(firstOpt?.conflicts ?? []);
383
+ // The primary may be a composite ("a+b+c"); every profile already inside it is
384
+ // off the table as a companion — re-offering it (and, with recents auto-
385
+ // checked, re-selecting it) is what duplicated profiles in the final selector.
386
+ const primaryParts = new Set(primary.split("+"));
387
+ const companionByName = new Map(companions.map((c) => [c.profile, c]));
388
+
389
+ // Ordered, de-duped candidates with their origin: recommends → history →
390
+ // detected → universal (featured/frequent/pinned, in that internal order).
391
+ // Earlier (stronger) sources keep the slot and the row hint on overlap; the
392
+ // origin drives the hint only for rows that appear *because* they're featured,
393
+ // frequently used, or a pinned always-on companion (gstack).
394
+ type CandidateOrigin = "autoSelect" | "recommends" | "history" | "detected" | UniversalOrigin;
395
+ const candidates: Array<{ name: string; origin: CandidateOrigin }> = [];
396
+ const seen = new Set<string>();
397
+ const addCandidate = (name: string, origin: CandidateOrigin) => {
398
+ if (seen.has(name)) return;
399
+ seen.add(name);
400
+ candidates.push({ name, origin });
401
+ };
402
+ // autoSelect is the strongest source: added first so it keeps the slot/origin
403
+ // even when the same name also appears in recommends or detection.
404
+ for (const a of autoSelect) addCandidate(a, "autoSelect");
405
+ for (const r of recommends) addCandidate(r, "recommends");
406
+ for (const r of pairSuggested) addCandidate(r, "history");
407
+ for (const c of companions) addCandidate(c.profile, "detected");
408
+ // Featured + frequent + pinned (gstack) all arrive via the one universal path.
409
+ for (const u of universalSuggestions) addCandidate(u.name, u.origin);
410
+
411
+ const companionOptions: AsciiMSOption[] = [];
412
+ const initialValues: string[] = [];
413
+ let frequentChecked = 0;
414
+ for (const { name, origin } of candidates) {
415
+ if (primaryParts.has(name)) continue;
416
+ if (primaryConflicts.has(name)) continue;
417
+ const opt = options.find((o) => o.value === name);
418
+ if (!opt || opt.divider === true || opt.value.includes("+")) continue;
419
+ // Symmetric conflict: the candidate declares the primary as a conflict.
420
+ if ((opt.conflicts ?? []).includes(primary)) continue;
421
+
422
+ const detected = companionByName.get(name);
423
+ // Detected rows show *why* they appeared; a history row shows it's a remembered
424
+ // pairing; a featured/frequent-only row shows its origin tag; everything else
425
+ // keeps the profile description.
426
+ let hint = opt.hint;
427
+ if (detected) hint = detected.reason;
428
+ else if (origin === "history") hint = HISTORY_HINT;
429
+ else if (origin === "featured") hint = FEATURED_HINT;
430
+ else if (origin === "frequent") hint = FREQUENT_HINT;
431
+ else if (origin === "pinned") hint = UNIVERSAL_HINT;
432
+ companionOptions.push({
433
+ value: opt.value,
434
+ label: opt.label,
435
+ hint,
436
+ conflicts: opt.conflicts,
437
+ recommended: origin === "recommends" || origin === "autoSelect",
438
+ });
439
+
440
+ // autoSelect rows start checked unconditionally — that's the whole point of
441
+ // the field (a profile that declares it needs the companion by default).
442
+ const checkByAutoSelect = origin === "autoSelect";
443
+ const checkByPreselect = opt.preselect === true;
444
+ const checkByDetect = detected !== undefined && detected.confidence >= args.autoCheckThreshold;
445
+ // Profiles you use often start checked — opt-out, not opt-in — but only the
446
+ // top few (see MAX_FREQUENT_AUTOCHECK); beyond the cap they're offered
447
+ // unchecked so a long recents tail can't auto-assemble a heavy stack.
448
+ // Featured cross-sells never auto-check (a discovery hint, not a pin).
449
+ // History partners (a remembered combo) are *offered unchecked* — a
450
+ // recommendation surfaced with the HISTORY_HINT, never silently re-pinned.
451
+ const checkByFrequent = origin === "frequent" && frequentChecked < MAX_FREQUENT_AUTOCHECK;
452
+ if (checkByAutoSelect || checkByPreselect || checkByDetect || checkByFrequent) {
453
+ initialValues.push(name);
454
+ if (checkByFrequent) frequentChecked += 1;
455
+ }
456
+ }
457
+
458
+ // Overflow = every other selectable profile, hidden behind the "show all" row.
459
+ // Same exclusions as a curated candidate, plus anything already shown above.
460
+ const shownValues = new Set(companionOptions.map((o) => o.value));
461
+ const overflowOptions: AsciiMSOption[] = [];
462
+ for (const opt of options) {
463
+ if (opt.divider === true || opt.top === true) continue;
464
+ if (opt.value.includes("+")) continue;
465
+ if (primaryParts.has(opt.value)) continue;
466
+ if (primaryConflicts.has(opt.value)) continue;
467
+ if ((opt.conflicts ?? []).includes(primary)) continue;
468
+ if (shownValues.has(opt.value)) continue;
469
+ overflowOptions.push({
470
+ value: opt.value,
471
+ label: opt.label,
472
+ hint: opt.hint,
473
+ conflicts: opt.conflicts,
474
+ });
475
+ }
476
+
477
+ // Show the multiselect when there's *anything* to combine with — a curated
478
+ // companion or just the overflow list (so "combine with X" stays reachable
479
+ // even when nothing is recommended for this primary).
480
+ if (companionOptions.length > 0 || overflowOptions.length > 0) {
481
+ // Lead with the escape hatch so the cursor's first stop (index 0) is
482
+ // "use <primary> alone": open the picker, press enter, launch the primary
483
+ // by itself — no navigation. The combine rows follow below it.
484
+ companionOptions.unshift({
485
+ value: SKIP_COMBINE,
486
+ label: `use ${primaryLabel} alone`,
487
+ hint: "",
488
+ kind: "action",
489
+ primaryLabel,
490
+ });
491
+ }
492
+ // Pin the "show all profiles" expand row at the end (rendered below the
493
+ // companion window). Toggling it reveals `overflowOptions` in place.
494
+ if (overflowOptions.length > 0) {
495
+ companionOptions.push({
496
+ value: SHOW_ALL,
497
+ label: "",
498
+ hint: "",
499
+ kind: "expand",
500
+ expandCount: overflowOptions.length,
501
+ });
502
+ }
503
+ // Mark the catalogue's "never use this" profile so the row carries a danger
504
+ // tag (matches the cue-combine design).
505
+ for (const o of overflowOptions) if (o.value === "full") o.danger = "never use this";
506
+ for (const o of companionOptions) if (o.value === "full") o.danger = "never use this";
507
+ // Group both lists into scannable category buckets. Navigation follows the
508
+ // option order, so sorting here (not just at render) keeps ↑↓ in step with
509
+ // the visible groups.
510
+ return {
511
+ companionOptions: groupByCategory(companionOptions),
512
+ initialValues,
513
+ overflowOptions: groupByCategory(overflowOptions),
514
+ };
515
+ }
516
+
517
+ /**
518
+ * A single profile's own resource identifiers, as lists so combined-profile
519
+ * previews can union them exactly (a skill/mcp/plugin shared by two stacked
520
+ * profiles is counted once). Skills mirror the picker headline: one entry per
521
+ * local skill + one per npx repo.
522
+ */
523
+ export interface ProfileTally {
524
+ skills: string[];
525
+ mcps: string[];
526
+ plugins: string[];
527
+ commands: string[];
528
+ /**
529
+ * This profile's own always-on token cost (skill-description frontmatter that
530
+ * loads into the skill router every session). Optional — when present, the
531
+ * combine preview sums it across the selection and soft-warns on a heavy
532
+ * stack. Summing slightly overcounts skills shared by two companions, so the
533
+ * displayed figure is an upper-bound estimate (rendered with a leading `~`).
534
+ */
535
+ alwaysOn?: number;
536
+ }
537
+
538
+ export interface TallyCounts {
539
+ skills: number;
540
+ mcps: number;
541
+ plugins: number;
542
+ commands: number;
543
+ }
544
+
545
+ const EMPTY_TALLY: ProfileTally = { skills: [], mcps: [], plugins: [], commands: [] };
546
+
547
+ /**
548
+ * "+17 skills · +1 mcp" — the per-row hint showing what a companion adds.
549
+ * Omits zero categories; returns "" for a profile that adds nothing. Pure.
550
+ */
551
+ export function formatTallyDelta(t: ProfileTally): string {
552
+ const parts: string[] = [];
553
+ const add = (n: number, one: string, many: string) => {
554
+ if (n > 0) parts.push(`+${n} ${n === 1 ? one : many}`);
555
+ };
556
+ add(t.skills.length, "skill", "skills");
557
+ add(t.mcps.length, "mcp", "mcps");
558
+ add(t.plugins.length, "plugin", "plugins");
559
+ add(t.commands.length, "cmd", "cmds");
560
+ return parts.join(" · ");
561
+ }
562
+
563
+ /** Count of the de-duped union across several profile tallies. Pure. */
564
+ export function unionTallyCounts(tallies: ProfileTally[]): TallyCounts {
565
+ const skills = new Set<string>();
566
+ const mcps = new Set<string>();
567
+ const plugins = new Set<string>();
568
+ const commands = new Set<string>();
569
+ for (const t of tallies) {
570
+ for (const s of t.skills) skills.add(s);
571
+ for (const m of t.mcps) mcps.add(m);
572
+ for (const p of t.plugins) plugins.add(p);
573
+ for (const c of t.commands) commands.add(c);
574
+ }
575
+ return { skills: skills.size, mcps: mcps.size, plugins: plugins.size, commands: commands.size };
576
+ }
577
+
578
+ /**
579
+ * The live "what you're about to pin" line under the combine list. Each segment
580
+ * reads `skills 31→48` when a companion changes the total, or `skills 31` when
581
+ * it doesn't; zero-count categories are dropped. Returns [] when there's nothing
582
+ * to show. Pure (no color) so it's directly testable.
583
+ */
584
+ export function formatCombinedPreview(baseline: TallyCounts, combined: TallyCounts): string[] {
585
+ const seg = (label: string, base: number, comb: number): string | null => {
586
+ if (comb === 0) return null;
587
+ return base === comb ? `${label} ${comb}` : `${label} ${base}→${comb}`;
588
+ };
589
+ const segs = [
590
+ seg("skills", baseline.skills, combined.skills),
591
+ seg("mcps", baseline.mcps, combined.mcps),
592
+ seg("plugins", baseline.plugins, combined.plugins),
593
+ seg("cmds", baseline.commands, combined.commands),
594
+ ].filter((s): s is string => s !== null);
595
+ return segs.length > 0 ? [segs.join(" · ")] : [];
596
+ }
597
+
598
+ /** Always-on token cost above which the combine preview soft-warns. Mirrors the
599
+ * 🟠 band in `tokenLevelEmoji` — the point at which the agent's own perf
600
+ * warning starts to fire. */
601
+ export const OVERHEAD_WARN_TOKENS = 10_000;
602
+
603
+ /**
604
+ * Column where a category header's count sits. The header rule fills out to here
605
+ * so every group's count lands in one stable column regardless of label width —
606
+ * a long name ("content & research") no longer collapses the rule to its 4-dash
607
+ * minimum while a short one ("commerce") gets a long one. Tuned to sit just past
608
+ * the 28-wide section dividers for a consistent right edge.
609
+ */
610
+ export const CATEGORY_RULE_COL = 30;
611
+
612
+ /**
613
+ * Soft-warning line for a heavy combined stack — "⚠ heavy: ~32k always-on 🔴 —
614
+ * slows the agent". Returns "" below the warn threshold so light combos stay
615
+ * uncluttered. The `~` flags it as an upper-bound estimate. Pure.
616
+ */
617
+ export function formatOverheadBadge(alwaysOnTokens: number): string {
618
+ if (alwaysOnTokens <= OVERHEAD_WARN_TOKENS) return "";
619
+ const k =
620
+ alwaysOnTokens >= 10_000
621
+ ? String(Math.round(alwaysOnTokens / 1000))
622
+ : (alwaysOnTokens / 1000).toFixed(1);
623
+ return `⚠ heavy: ~${k}k always-on ${tokenLevelEmoji(alwaysOnTokens)} — slows the agent`;
624
+ }
625
+
626
+ /**
627
+ * Whether to render profile icons in ASCII-safe mode. Emoji and Private-Use
628
+ * glyphs (vite ⚡, nextjs ▲, vercel 🔺) show as tofu boxes in fonts that lack
629
+ * them. We can't probe a font's glyph coverage from Node — only the locale — so
630
+ * the env var `CUE_ASCII_ICONS=1` is the reliable opt-in; a non-UTF-8 locale
631
+ * flips it on automatically. Default off (icons shown).
632
+ */
633
+ export function asciiIconsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
634
+ if (/^(1|true|yes)$/i.test(env.CUE_ASCII_ICONS ?? "")) return true;
635
+ const loc = env.LC_ALL || env.LC_CTYPE || env.LANG || "";
636
+ return loc !== "" && !/utf-?8/i.test(loc);
637
+ }
638
+
639
+ /**
640
+ * Strip a leading icon cluster (emoji + variation selectors / ZWJ) from a label
641
+ * when `ascii` is on, so "🔺 vercel" → "vercel". Pure-ASCII labels and labels
642
+ * that are *entirely* non-ASCII (e.g. CJK names) are returned unchanged. Pure.
643
+ */
644
+ export function stripIconIfAscii(label: string, ascii: boolean): string {
645
+ if (!ascii) return label;
646
+ const stripped = label.replace(/^[^\x00-\x7F]+\s*/u, "").trimStart();
647
+ return stripped.length > 0 ? stripped : label;
648
+ }
649
+
650
+ /**
651
+ * Approximate the rendered cell width of a string in a monospace terminal.
652
+ * Emoji and CJK glyphs occupy two cells; variation selectors, ZWJ, and skin-
653
+ * tone modifiers occupy none; everything else one. Good enough to column-align
654
+ * the combine rows (whose labels carry leading emoji icons) — not a full
655
+ * grapheme segmenter. Pure.
656
+ */
657
+ export function displayWidth(s: string): number {
658
+ let w = 0;
659
+ for (const ch of s) {
660
+ const cp = ch.codePointAt(0)!;
661
+ // zero-width: ZWJ, variation selectors, skin-tone modifiers
662
+ if (cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f) || (cp >= 0x1f3fb && cp <= 0x1f3ff)) {
663
+ continue;
664
+ }
665
+ // wide: CJK blocks, fullwidth forms, and the emoji/supplementary planes
666
+ if (
667
+ (cp >= 0x1100 && cp <= 0x115f) ||
668
+ (cp >= 0x2e80 && cp <= 0xa4cf) ||
669
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
670
+ (cp >= 0xf900 && cp <= 0xfaff) ||
671
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
672
+ (cp >= 0xff00 && cp <= 0xff60) ||
673
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
674
+ cp >= 0x1f000
675
+ ) {
676
+ w += 2;
677
+ continue;
678
+ }
679
+ w += 1;
680
+ }
681
+ return w;
682
+ }
683
+
684
+ /**
685
+ * Collapse a profile combo to "first +N more" once it exceeds `max` parts, so
686
+ * the confirm row never wraps. `<= max` parts render in full. Used for the
687
+ * skip-combine action label, where the primary may itself be a composite
688
+ * (`a + b + c`) and a handful of companions push the line off-screen. Pure.
689
+ */
690
+ export function compressCombo(parts: string[], max = 3): string {
691
+ if (parts.length <= max) return parts.join(" + ");
692
+ return `${parts[0]} +${parts.length - 1} more`;
693
+ }
694
+
695
+ /**
696
+ * Flatten composite picks (`"a+b"`) to their parts and drop duplicates,
697
+ * preserving first-seen order. The combine picker's primary may already be a
698
+ * composite, so a companion inside it — or one picked twice — must not double
699
+ * up in the final selector, the runtime dir name, or the summary. Pure.
700
+ *
701
+ * Control sentinels (SHOW_ALL / SKIP_COMBINE) are dropped here as a write-
702
+ * boundary backstop: the upstream filters (asciiMultiselect strips SHOW_ALL,
703
+ * runPicker guards on SKIP_COMBINE) are the primary defense, but this is the
704
+ * last transform before the selector is joined and persisted to .cue-profile,
705
+ * so a sentinel must never survive it even if an upstream path regresses.
706
+ */
707
+ const CONTROL_SENTINELS = new Set<string>([SHOW_ALL, SKIP_COMBINE]);
708
+ export function dedupeSelectorParts(picks: string[]): string[] {
709
+ const out: string[] = [];
710
+ for (const pick of picks) {
711
+ for (const part of pick.split("+")) {
712
+ if (part.length > 0 && !CONTROL_SENTINELS.has(part) && !out.includes(part)) out.push(part);
713
+ }
714
+ }
715
+ return out;
716
+ }
717
+
718
+ /** State the combine multiselect frame is rendered from. Decoupled from the
719
+ * live @clack prompt so the frame is unit-testable without a TTY. */
720
+ export interface CombineFrameState {
721
+ message: string;
722
+ options: AsciiMSOption[];
723
+ /** Index of the focused row. */
724
+ cursor: number;
725
+ /** Raw selected values, pre-conflict-resolution (the prompt's live value). */
726
+ selected: string[];
727
+ /** Per-row hints + live combined-total preview; omit for neither. */
728
+ preview?: { primary: string; tallies: Map<string, ProfileTally> };
729
+ /** Force ASCII icon mode. Defaults to `asciiIconsEnabled()`. */
730
+ ascii?: boolean;
731
+ /**
732
+ * Max companion rows to show at once. When the companion list is longer it
733
+ * scrolls around the cursor with "↑/↓ N more" markers (the action row, preview
734
+ * and footer stay pinned). Unset / ≤0 → show every companion (no window).
735
+ */
736
+ maxRows?: number;
737
+ }
738
+
739
+ /**
740
+ * Render one frame of the combine multiselect. Pure: same state in → same
741
+ * string out, no TTY, no prompt object. `asciiMultiselect` delegates its live
742
+ * render here so the displayed frame and the tested frame are the same code.
743
+ *
744
+ * `styleText` is a no-op when stdout isn't a TTY (as in tests), so assertions
745
+ * match on plain text.
746
+ */
747
+ export function renderCombineFrame(state: CombineFrameState): string {
748
+ const BAR = styleText("gray", "│");
749
+ const conflictMap = buildConflictMap(state.options);
750
+ // Apply conflict resolution to the live value so the display matches what
751
+ // we'd actually return on confirm. The underlying MultiSelectPrompt may hold
752
+ // a conflicting value internally (we can't easily block its toggle), but the
753
+ // user never sees it selected — and confirm strips it for real.
754
+ const effective = new Set(resolveConflicts(state.selected, conflictMap));
755
+ // Skip row on → primary-alone: the ticked companions are overridden, so they
756
+ // count as nothing for the preview and footer tally.
757
+ const skipping = effective.has(SKIP_COMBINE);
758
+ const ascii = state.ascii ?? asciiIconsEnabled();
759
+ const icon = (s: string) => stripIconIfAscii(s, ascii);
760
+ // Column where every companion's "+N skills" delta starts. Measured across
761
+ // the full companion set (not just the visible window) so the deltas line up
762
+ // in a stable column as the list scrolls. Capped so one long name can't push
763
+ // the whole table off a narrow terminal.
764
+ const labelCol = Math.min(
765
+ 24,
766
+ state.options
767
+ .filter((o) => o.kind === undefined)
768
+ .reduce((m, o) => Math.max(m, displayWidth(icon(o.label))), 0),
769
+ );
770
+ const lines: string[] = [];
771
+ lines.push(`${BAR}`);
772
+ lines.push(`${BAR} ${state.message}`);
773
+ lines.push(`${BAR}`);
774
+ // One row's rendering, shared by the pinned action row and the windowed
775
+ // companion list below it.
776
+ const renderRow = (o: AsciiMSOption, idx: number) => {
777
+ const isCursor = idx === state.cursor;
778
+ const isSel = effective.has(o.value);
779
+ // The `→`/"recommended" affordance flags a curated *companion* pairing; it
780
+ // must never decorate a control row (skip-combine / show-all), so guard on
781
+ // the row kind even though `buildCompanionOptions` only sets `recommended`
782
+ // on real companions today. Keeps `renderCombineFrame` honest for callers
783
+ // (and tests) that hand-build options.
784
+ const isRecommended = o.recommended === true && o.kind === undefined;
785
+ // Gutter glyph: the cursor `›` always wins; otherwise a `→` flags a
786
+ // recommended companion so the suggested pairing reads at a glance.
787
+ const arrow = isCursor
788
+ ? styleText("cyan", "›")
789
+ : isRecommended
790
+ ? styleText("magenta", "→")
791
+ : " ";
792
+
793
+ if (o.kind === "action") {
794
+ // Narrate what enter does *right now*: toggled on, this row forces
795
+ // primary-alone (skips the checked companions); toggled off, it
796
+ // mirrors the live combination so the confirm line never lies.
797
+ const combo = [...effective]
798
+ .filter((v) => v !== SKIP_COMBINE && v !== SHOW_ALL)
799
+ .map((v) => icon(state.options.find((opt) => opt.value === v)?.label ?? v));
800
+ // The primary may itself be a composite ("a + b + c"); split it so the
801
+ // combo count is real and `compressCombo` can fold a long line to
802
+ // "first +N more" instead of wrapping across the screen.
803
+ const primaryParts = icon(o.primaryLabel ?? "").split(" + ");
804
+ let dynamicLabel = o.label;
805
+ if (o.primaryLabel) {
806
+ dynamicLabel =
807
+ isSel || combo.length === 0
808
+ ? primaryParts.length <= 3
809
+ ? `use ${icon(o.primaryLabel)} alone`
810
+ : `use ${compressCombo(primaryParts)}`
811
+ : `use ${compressCombo([...primaryParts, ...combo])}`;
812
+ }
813
+ const glyph = styleText(isSel ? "cyan" : "dim", "↩");
814
+ const labelStyled = isSel
815
+ ? styleText("cyan", dynamicLabel)
816
+ : isCursor
817
+ ? dynamicLabel
818
+ : styleText("dim", dynamicLabel);
819
+ // Toggled on → it overrides the checks; toggled off with a combo
820
+ // staged → point at the enter key so the confirm path is obvious.
821
+ const marker = isSel
822
+ ? styleText("cyan", " ← will skip combining")
823
+ : combo.length > 0
824
+ ? styleText("dim", " ↵ enter to confirm")
825
+ : "";
826
+ lines.push(`${BAR} ${arrow} ${glyph} ${labelStyled}${marker}`);
827
+ return;
828
+ }
829
+
830
+ // Conflict-blocked: another currently-selected option lists this
831
+ // value in its conflicts (or vice-versa via the symmetric map).
832
+ // Render disabled so the user can see why a toggle "doesn't take."
833
+ let blocker: string | null = null;
834
+ if (!isSel) {
835
+ const partners = conflictMap.get(o.value);
836
+ if (partners) {
837
+ for (const sel of effective) {
838
+ if (partners.has(sel)) { blocker = sel; break; }
839
+ }
840
+ }
841
+ }
842
+
843
+ if (blocker) {
844
+ const box = styleText("dim", "[—]");
845
+ const labelStyled = styleText("dim", icon(o.label));
846
+ const conflictHint = styleText("dim", ` (conflicts with ${blocker})`);
847
+ lines.push(`${BAR} ${arrow} ${box} ${labelStyled}${conflictHint}`);
848
+ return;
849
+ }
850
+
851
+ const box = isSel ? styleText("green", "[x]") : styleText("dim", "[ ]");
852
+ const rawLabel = icon(o.label);
853
+ const labelStyled = isSel || isCursor ? rawLabel : styleText("dim", rawLabel);
854
+ // Contribution at a glance: every row shows just the headline "+N skills"
855
+ // (one token, never wraps); the focused row expands to the full
856
+ // "+N skills · +M mcps · …" breakdown so detail is one keystroke away.
857
+ const tally = state.preview ? state.preview.tallies.get(o.value) ?? EMPTY_TALLY : null;
858
+ const delta = tally
859
+ ? isCursor
860
+ ? formatTallyDelta(tally)
861
+ : tally.skills.length > 0
862
+ ? `+${tally.skills.length} skills`
863
+ : ""
864
+ : "";
865
+ // The verbose reason / description (detection signal, profile blurb)
866
+ // stays cursor-only to keep the unfocused rows scannable.
867
+ const hint = o.hint && isCursor ? styleText("dim", ` (${o.hint})`) : "";
868
+ // A dim trailing tag labels the `→` marker, so it reads "recommended" even
869
+ // when the cursor sits on the row (gutter shows `›`, not `→`).
870
+ const recTag = isRecommended ? styleText("dim", " recommended") : "";
871
+ // Pad the label out to the shared delta column so every "+N skills" lines
872
+ // up in a clean table (≥2-space gap even for an over-long name). Skip the
873
+ // pad entirely when there's no trailer, so bare rows carry no trailing
874
+ // whitespace.
875
+ const hasTrailer = delta !== "" || (Boolean(o.hint) && isCursor) || Boolean(o.danger);
876
+ const pad = hasTrailer ? " ".repeat(Math.max(2, labelCol + 2 - displayWidth(rawLabel))) : "";
877
+ const deltaStr = delta ? styleText("dim", delta) : "";
878
+ // Danger profiles (e.g. `full`, "never use this") carry a red trailing tag.
879
+ const dangerTag = o.danger ? styleText("red", `${delta ? " · " : ""}${o.danger}`) : "";
880
+ lines.push(`${BAR} ${arrow} ${box} ${labelStyled}${pad}${deltaStr}${dangerTag}${hint}${recTag}`);
881
+ };
882
+
883
+ // The lead action row ("use X alone / + …") stays pinned on top; only the
884
+ // companion list below it scrolls, so a long companion list can't push the
885
+ // confirm row or preview off a short terminal. `maxRows` unset → no window.
886
+ const rows = state.options.map((o, idx) => ({ o, idx }));
887
+ const actionRows = rows.filter((r) => r.o.kind === "action");
888
+ const expandRows = rows.filter((r) => r.o.kind === "expand");
889
+ const companions = rows.filter((r) => r.o.kind !== "action" && r.o.kind !== "expand");
890
+ for (const r of actionRows) renderRow(r.o, r.idx);
891
+ if (companions.length > 0) {
892
+ lines.push(`${BAR} ${styleText("dim", "─".repeat(28))}`);
893
+ const max = state.maxRows && state.maxRows > 0 ? state.maxRows : companions.length;
894
+ const cursorPos = companions.findIndex((r) => r.idx === state.cursor);
895
+ // Cursor off the companions (on the trailing expand / "show all" row) →
896
+ // findIndex returns -1. Pin the window to the *bottom* of the list, not the
897
+ // top: the user reaches the expand row by arrowing down off the last
898
+ // companion, so keeping the bottom in view makes that step seamless instead
899
+ // of snapping the long curated list back to its first rows.
900
+ const activePos = cursorPos < 0 ? companions.length - 1 : cursorPos;
901
+ const win = windowOptions(companions, activePos, max);
902
+ if (win.hiddenAbove > 0) lines.push(`${BAR} ${styleText("dim", `↑ ${win.hiddenAbove} more`)}`);
903
+ // Per-category totals (across the whole list, not just the window) for the
904
+ // header counts — turns the flat wall into scannable groups (cue-combine
905
+ // design). A header prints before the first visible row of each category,
906
+ // including when the window opens mid-group.
907
+ const catTotals = new Map<string, number>();
908
+ for (const r of companions) {
909
+ const c = r.o.category;
910
+ if (c) catTotals.set(c, (catTotals.get(c) ?? 0) + 1);
911
+ }
912
+ let lastCat: string | undefined;
913
+ for (const r of win.items) {
914
+ const cat = r.o.category;
915
+ if (cat && cat !== lastCat) {
916
+ // Blank spacer between groups (never before the first / window top) so
917
+ // the categories read as distinct blocks, not one running wall.
918
+ if (lastCat !== undefined) lines.push(BAR);
919
+ const n = catTotals.get(cat) ?? 0;
920
+ const countStr = String(n);
921
+ // Bold bright-blue label so the group header pops above the dim rows,
922
+ // with the rule filling to a fixed column (CATEGORY_RULE_COL) so every
923
+ // count lines up — a long name no longer collapses the rule to a stub.
924
+ const label = styleText("bold", styleText("blueBright", cat));
925
+ const ruleLen = Math.max(3, CATEGORY_RULE_COL - displayWidth(cat) - 2);
926
+ const rule = styleText("gray", "─".repeat(ruleLen));
927
+ lines.push(`${BAR} ${label} ${rule} ${styleText("dim", countStr)}`);
928
+ lastCat = cat;
929
+ }
930
+ renderRow(r.o, r.idx);
931
+ }
932
+ if (win.hiddenBelow > 0) lines.push(`${BAR} ${styleText("dim", `↓ ${win.hiddenBelow} more`)}`);
933
+ }
934
+ // "Show all profiles" expand row — pinned below the window so a long curated
935
+ // list never pushes it off-screen. Toggling it reveals the overflow in place
936
+ // (asciiMultiselect appends the rows and the row removes itself).
937
+ for (const r of expandRows) {
938
+ const isCursor = r.idx === state.cursor;
939
+ const arrow = isCursor ? styleText("cyan", "›") : " ";
940
+ // Reveal is a SPACE toggle, not enter. Don't reuse the action row's ↩ (enter)
941
+ // glyph here — pressing enter on this row confirms the whole prompt. A ▾ +
942
+ // explicit "(space)" hint points at the key that actually expands.
943
+ const glyph = styleText(isCursor ? "cyan" : "dim", "▾");
944
+ const text = `show all ${r.o.expandCount} profiles (space)`;
945
+ const labelStyled = isCursor ? text : styleText("dim", text);
946
+ lines.push(`${BAR} ${styleText("dim", "─".repeat(28))}`);
947
+ lines.push(`${BAR} ${arrow} ${glyph} ${labelStyled}`);
948
+ }
949
+
950
+ // Live combined-total preview: the resources you'd actually pin, updated
951
+ // as you toggle. Skipping (action row on) collapses it to the primary.
952
+ if (state.preview) {
953
+ lines.push(`${BAR}`);
954
+ const { primary, tallies } = state.preview;
955
+ const baseTally = tallies.get(primary) ?? EMPTY_TALLY;
956
+ const selected = skipping
957
+ ? [baseTally]
958
+ : [
959
+ baseTally,
960
+ ...[...effective]
961
+ .filter((v) => v !== SKIP_COMBINE && v !== SHOW_ALL)
962
+ .map((v) => tallies.get(v) ?? EMPTY_TALLY),
963
+ ];
964
+ const previewLines = formatCombinedPreview(unionTallyCounts([baseTally]), unionTallyCounts(selected));
965
+ for (const pl of previewLines) lines.push(`${BAR} ${styleText("dim", `→ ${pl}`)}`);
966
+ // Soft-warn when the combined always-on cost is heavy — at decision time,
967
+ // not after materialize. Summing per-profile overhead slightly overcounts
968
+ // shared skills, so it's an upper bound (the `~` says so).
969
+ const combinedAlwaysOn = selected.reduce((sum, t) => sum + (t.alwaysOn ?? 0), 0);
970
+ const badge = formatOverheadBadge(combinedAlwaysOn);
971
+ if (badge) lines.push(`${BAR} ${styleText("yellow", badge)}`);
972
+ }
973
+
974
+ const staged = skipping
975
+ ? 0
976
+ : [...effective].filter((v) => v !== SKIP_COMBINE && v !== SHOW_ALL).length;
977
+ // Lead the footer with the enter affordance — the #1 question at this screen
978
+ // is "how do I move on with what I ticked". Brighten it (cyan) and name the
979
+ // count once something is staged, so "press enter to continue" is never a
980
+ // guess. Nav keys trail behind, dim.
981
+ const enterText =
982
+ staged > 0 ? `enter to continue with ${staged} selected` : "enter to continue";
983
+ const enterStyled = styleText(staged > 0 ? "cyan" : "dim", `⏎ ${enterText}`);
984
+ const navStyled = styleText("dim", " · space toggle · ↑↓ move · esc cancel");
985
+ lines.push(`${BAR}`);
986
+ lines.push(`${BAR} ${enterStyled}${navStyled}`);
987
+ return lines.join("\n");
988
+ }
989
+
990
+ /**
991
+ * Compute the option/value/cursor state after the user toggles the "show all
992
+ * profiles" expand row. Reveal is one-way: when `SHOW_ALL` is present in the
993
+ * live selection, the expand row is removed, the `overflow` rows are appended,
994
+ * the `SHOW_ALL` sentinel is dropped from the selection, and the cursor lands on
995
+ * the first newly-revealed row (where the expand row used to sit). When the
996
+ * sentinel isn't selected, `expanded` is false and the inputs pass through
997
+ * unchanged.
998
+ *
999
+ * Pure (returns fresh arrays) + exported so the reveal logic is unit-testable
1000
+ * without driving a live `MultiSelectPrompt` over a TTY; `asciiMultiselect`
1001
+ * assigns the result back onto the prompt.
1002
+ */
1003
+ export function applyShowAllExpansion(args: {
1004
+ options: AsciiMSOption[];
1005
+ value: readonly string[];
1006
+ cursor: number;
1007
+ overflow: AsciiMSOption[];
1008
+ }): { options: AsciiMSOption[]; value: string[]; cursor: number; expanded: boolean } {
1009
+ const { options, value, cursor, overflow } = args;
1010
+ if (!value.includes(SHOW_ALL)) {
1011
+ return { options, value: [...value], cursor, expanded: false };
1012
+ }
1013
+ const idx = options.findIndex((o) => o.value === SHOW_ALL);
1014
+ const nextOptions = options.filter((o) => o.value !== SHOW_ALL);
1015
+ nextOptions.push(...overflow);
1016
+ return {
1017
+ options: nextOptions,
1018
+ value: value.filter((v) => v !== SHOW_ALL),
1019
+ // Land on the first revealed row (the old expand-row slot); fall back to the
1020
+ // current cursor if the sentinel somehow wasn't in the option list.
1021
+ cursor: idx >= 0 ? idx : cursor,
1022
+ expanded: true,
1023
+ };
1024
+ }
1025
+
164
1026
  async function asciiMultiselect(opts: {
165
1027
  message: string;
166
1028
  options: AsciiMSOption[];
167
1029
  initialValues?: string[];
168
1030
  required?: boolean;
1031
+ /**
1032
+ * When provided, render per-row "+N skills" hints and a live combined-total
1033
+ * preview line. `primary` is the always-present base profile; `tallies` maps
1034
+ * each profile value (primary + every companion) to its own resources.
1035
+ */
1036
+ preview?: { primary: string; tallies: Map<string, ProfileTally> };
1037
+ /**
1038
+ * Rows revealed when the user toggles the "show all profiles" expand row.
1039
+ * `onReveal` (optional) is invoked once on expansion — e.g. to lazily fill
1040
+ * the preview tallies for the newly-shown profiles — and the prompt re-renders
1041
+ * when it resolves. Absent → no expand behavior even if a SHOW_ALL row exists.
1042
+ */
1043
+ overflow?: {
1044
+ options: AsciiMSOption[];
1045
+ onReveal?: () => Promise<void> | void;
1046
+ };
169
1047
  }): Promise<string[] | symbol> {
170
- const BAR = styleText("gray", "│");
171
- const conflictMap = buildConflictMap(opts.options);
1048
+ // Build from curated + overflow so the confirm-time strip knows every
1049
+ // declarable conflict, including one between two profiles that only appear
1050
+ // after "show all" is revealed. resolveConflicts acts only on values actually
1051
+ // selected, so seeding the map with not-yet-revealed options is harmless — and
1052
+ // skipping them is the CRITICAL bug where medusa-vite + medusa-next both
1053
+ // survive into the written .cue-profile.
1054
+ const conflictMap = buildConflictMap([
1055
+ ...opts.options,
1056
+ ...(opts.overflow?.options ?? []),
1057
+ ]);
172
1058
  const prompt = new MultiSelectPrompt<AsciiMSOption>({
173
1059
  options: opts.options,
174
1060
  initialValues: opts.initialValues,
175
1061
  required: opts.required ?? false,
176
1062
  render() {
177
- // Apply conflict resolution to the live value so the display matches
178
- // what we'd actually return on confirm. The underlying MultiSelectPrompt
179
- // may have a conflicting value internally (we can't easily block its
180
- // toggle), but the user never sees it as selected — and confirm strips
181
- // it for real.
182
- const rawValue = (this.value ?? []) as string[];
183
- const effective = new Set(resolveConflicts(rawValue, conflictMap));
184
- const lines: string[] = [];
185
- lines.push(`${BAR}`);
186
- lines.push(`${BAR} ${opts.message}`);
187
- this.options.forEach((o, idx) => {
188
- const isCursor = idx === this.cursor;
189
- const isSel = effective.has(o.value);
190
- const arrow = isCursor ? styleText("cyan", "›") : " ";
191
-
192
- if (o.kind === "action") {
193
- const prev = this.options[idx - 1];
194
- if (prev && prev.kind !== "action") {
195
- lines.push(`${BAR} ${styleText("dim", "─".repeat(28))}`);
196
- }
197
- const glyph = styleText(isSel ? "cyan" : "dim", "↩");
198
- const labelStyled = isSel
199
- ? styleText("cyan", o.label)
200
- : isCursor
201
- ? o.label
202
- : styleText("dim", o.label);
203
- const marker = isSel ? styleText("cyan", " ← will skip combining") : "";
204
- lines.push(`${BAR} ${arrow} ${glyph} ${labelStyled}${marker}`);
205
- return;
206
- }
207
-
208
- // Conflict-blocked: another currently-selected option lists this
209
- // value in its conflicts (or vice-versa via the symmetric map).
210
- // Render disabled so the user can see why a toggle "doesn't take."
211
- let blocker: string | null = null;
212
- if (!isSel) {
213
- const partners = conflictMap.get(o.value);
214
- if (partners) {
215
- for (const sel of effective) {
216
- if (partners.has(sel)) { blocker = sel; break; }
217
- }
218
- }
219
- }
220
-
221
- if (blocker) {
222
- const box = styleText("dim", "[—]");
223
- const labelStyled = styleText("dim", o.label);
224
- const conflictHint = styleText("dim", ` (conflicts with ${blocker})`);
225
- lines.push(`${BAR} ${arrow} ${box} ${labelStyled}${conflictHint}`);
226
- return;
227
- }
228
-
229
- const box = isSel ? styleText("green", "[x]") : styleText("dim", "[ ]");
230
- const labelStyled = isSel || isCursor ? o.label : styleText("dim", o.label);
231
- const hint = o.hint && isCursor ? styleText("dim", ` (${o.hint})`) : "";
232
- lines.push(`${BAR} ${arrow} ${box} ${labelStyled}${hint}`);
1063
+ // Reserve rows for our header (2), the pinned action row + divider (2),
1064
+ // the "show all" expand row + its divider (2), the preview + overhead
1065
+ // lines (3), the footer (1) and the ↑/↓ markers (2); floor at 4 so a
1066
+ // short terminal still shows a usable window.
1067
+ const termRows =
1068
+ (this as unknown as { output?: { rows?: number } }).output?.rows ?? process.stdout.rows ?? 24;
1069
+ return renderCombineFrame({
1070
+ message: opts.message,
1071
+ options: this.options,
1072
+ cursor: this.cursor,
1073
+ selected: (this.value ?? []) as string[],
1074
+ preview: opts.preview,
1075
+ maxRows: Math.max(4, termRows - 12),
233
1076
  });
234
- lines.push(
235
- `${BAR} ${styleText("dim", "↑↓ move · space toggle · enter confirm · esc cancel")}`,
236
- );
237
- return lines.join("\n");
238
1077
  },
239
1078
  });
1079
+ // "Show all profiles": when the user toggles the SHOW_ALL expand row,
1080
+ // `applyShowAllExpansion` computes the revealed option/value/cursor state and
1081
+ // we assign it back onto the live prompt (MultiSelect reads `this.options`/
1082
+ // `this.value` on every nav/toggle, so reassigning the fields just works).
1083
+ // One-way — once revealed, the rows stay. `onReveal` lazily fills preview
1084
+ // tallies, then we re-render so the new rows show their counts.
1085
+ if (opts.overflow && opts.overflow.options.length > 0) {
1086
+ const overflow = opts.overflow;
1087
+ let expanded = false;
1088
+ const live = prompt as unknown as {
1089
+ options: AsciiMSOption[];
1090
+ value?: string[];
1091
+ cursor: number;
1092
+ render: () => void;
1093
+ };
1094
+ prompt.on("key", () => {
1095
+ if (expanded) return;
1096
+ const next = applyShowAllExpansion({
1097
+ options: live.options,
1098
+ value: (live.value ?? []) as string[],
1099
+ cursor: live.cursor,
1100
+ overflow: overflow.options,
1101
+ });
1102
+ if (!next.expanded) return;
1103
+ expanded = true;
1104
+ live.options = next.options;
1105
+ live.value = next.value;
1106
+ live.cursor = next.cursor;
1107
+ const revealed = overflow.onReveal?.();
1108
+ if (revealed && typeof (revealed as Promise<void>).then === "function") {
1109
+ void (revealed as Promise<void>).then(() => live.render());
1110
+ }
1111
+ });
1112
+ }
240
1113
  const result = await prompt.prompt();
241
1114
  if (typeof result === "symbol") return result;
242
- // Final pass: strip conflict-losers from the returned selection so callers
243
- // always receive a conflict-free list, regardless of what the underlying
244
- // prompt's internal value contained.
245
- return resolveConflicts(result as string[], conflictMap);
1115
+ // Final pass: strip conflict-losers + the SHOW_ALL sentinel from the returned
1116
+ // selection so callers always receive a conflict-free list of real profiles,
1117
+ // regardless of what the underlying prompt's internal value contained.
1118
+ const cleaned = (result as string[]).filter((v) => v !== SHOW_ALL);
1119
+ return resolveConflicts(cleaned, conflictMap);
1120
+ }
1121
+
1122
+ /**
1123
+ * Filter the option list by a typed query.
1124
+ *
1125
+ * - empty query → every option, dividers kept as section headers, all
1126
+ * non-divider rows are selectable.
1127
+ * - non-empty query → dividers dropped (section headers are noise once the
1128
+ * list is filtered) and only matching rows survive. A row matches if its
1129
+ * `value` *starts with* the query (the requested behavior: press "s" →
1130
+ * slack, studio, secops, stripe…). If nothing starts with the query we
1131
+ * fall back to a substring match on value or label, so a mid-word search
1132
+ * still finds something instead of a dead end.
1133
+ *
1134
+ * Pure + exported so the matching rules can be unit-tested without a TTY.
1135
+ */
1136
+ export function filterOptions(
1137
+ options: PickerOption[],
1138
+ query: string,
1139
+ ): { display: PickerOption[]; selectable: PickerOption[] } {
1140
+ const q = query.trim().toLowerCase();
1141
+ if (q.length === 0) {
1142
+ return { display: options, selectable: options.filter((o) => o.divider !== true) };
1143
+ }
1144
+ const rows = options.filter((o) => o.divider !== true);
1145
+ const startsWith = rows.filter((o) => o.value.toLowerCase().startsWith(q));
1146
+ const pool =
1147
+ startsWith.length > 0
1148
+ ? startsWith
1149
+ : rows.filter(
1150
+ (o) => o.value.toLowerCase().includes(q) || o.label.toLowerCase().includes(q),
1151
+ );
1152
+ return { display: pool, selectable: pool };
1153
+ }
1154
+
1155
+ /**
1156
+ * Slice a list down to a scrolling window of at most `max` rows, centered on
1157
+ * `activeIndex`. Returns the visible slice plus how many rows are hidden above
1158
+ * and below (for "↑/↓ N more" indicators). When everything fits, the whole
1159
+ * list is returned with zero hidden. The active row stays centered until the
1160
+ * window hits either end, then it pins so the last/first rows stay reachable.
1161
+ *
1162
+ * Pure + exported so the scroll math is unit-testable without a TTY.
1163
+ */
1164
+ export function windowOptions<T>(
1165
+ items: T[],
1166
+ activeIndex: number,
1167
+ max: number,
1168
+ ): { items: T[]; start: number; hiddenAbove: number; hiddenBelow: number } {
1169
+ if (max <= 0 || items.length <= max) {
1170
+ return { items, start: 0, hiddenAbove: 0, hiddenBelow: 0 };
1171
+ }
1172
+ let start = activeIndex - Math.floor(max / 2);
1173
+ start = Math.max(0, Math.min(start, items.length - max));
1174
+ const end = start + max;
1175
+ return {
1176
+ items: items.slice(start, end),
1177
+ start,
1178
+ hiddenAbove: start,
1179
+ hiddenBelow: items.length - end,
1180
+ };
1181
+ }
1182
+
1183
+ // Interactive single-select with type-to-filter. clack's built-in `p.select`
1184
+ // has no live filtering, so we drive @clack/core's base Prompt directly: with
1185
+ // key-tracking on, printable keys buffer into `this.userInput` (readline owns
1186
+ // backspace) and only the real arrow keys emit `cursor` events — j/k/h/l type
1187
+ // into the filter instead of moving the cursor, which is what you want in a
1188
+ // search box.
1189
+ export class FilterSelectPrompt extends Prompt<string> {
1190
+ message: string;
1191
+ allOptions: PickerOption[];
1192
+ display: PickerOption[] = [];
1193
+ selectable: PickerOption[] = [];
1194
+ cursor = 0;
1195
+ query = "";
1196
+
1197
+ constructor(message: string, options: PickerOption[]) {
1198
+ // The render fn's `this` is the FilterSelectPrompt (bound by the base
1199
+ // Prompt), but the constructor types it against Prompt<string>; the cast
1200
+ // bridges that contravariance. Runtime binding is correct.
1201
+ super(
1202
+ {
1203
+ render(this: FilterSelectPrompt) {
1204
+ return this.renderFrame();
1205
+ },
1206
+ } as unknown as PromptOptions<string, Prompt<string>>,
1207
+ true,
1208
+ );
1209
+ this.message = message;
1210
+ this.allOptions = options;
1211
+ this.recompute();
1212
+
1213
+ this.on("cursor", (dir) => {
1214
+ const n = this.selectable.length;
1215
+ if (n === 0) return;
1216
+ if (dir === "up") this.cursor = (this.cursor - 1 + n) % n;
1217
+ else if (dir === "down") this.cursor = (this.cursor + 1) % n;
1218
+ this.syncValue();
1219
+ });
1220
+
1221
+ // `key` fires on every keypress (including arrows). We only re-filter when
1222
+ // the typed buffer actually changed, so arrow navigation doesn't reset it.
1223
+ this.on("key", () => {
1224
+ const next = (this.userInput ?? "").trim().toLowerCase();
1225
+ if (next === this.query) return;
1226
+ this.query = next;
1227
+ this.cursor = 0;
1228
+ this.recompute();
1229
+ });
1230
+ }
1231
+
1232
+ private recompute(): void {
1233
+ const { display, selectable } = filterOptions(this.allOptions, this.query);
1234
+ this.display = display;
1235
+ this.selectable = selectable;
1236
+ if (this.cursor >= this.selectable.length) this.cursor = 0;
1237
+ this.syncValue();
1238
+ }
1239
+
1240
+ private syncValue(): void {
1241
+ this.value = this.selectable[this.cursor]?.value;
1242
+ }
1243
+
1244
+ // Rows available for option rows, derived from terminal height. Reserve space
1245
+ // for the intro line, our 2-line header, the footer, and the pin-confirm +
1246
+ // outro clack draws below — plus the two scroll indicators. Floor at 5 so a
1247
+ // short terminal still shows a usable window.
1248
+ private visibleRows(): number {
1249
+ const rows =
1250
+ (this.output as { rows?: number } | undefined)?.rows ?? process.stdout.rows ?? 24;
1251
+ return Math.max(5, rows - 10);
1252
+ }
1253
+
1254
+ // Block submit on an empty result set so enter can't return undefined.
1255
+ protected override _shouldSubmit(): boolean {
1256
+ return this.selectable.length > 0;
1257
+ }
1258
+
1259
+ // Bound to the instance by the base Prompt (`_render = render.bind(this)`),
1260
+ // so `this` here is the live prompt.
1261
+ renderFrame(this: FilterSelectPrompt): string {
1262
+ const BAR = styleText("gray", "│");
1263
+ const ascii = asciiIconsEnabled();
1264
+ const icon = (s: string) => stripIconIfAscii(s, ascii);
1265
+
1266
+ if (this.state === "submit") {
1267
+ const chosen = this.allOptions.find((o) => o.value === this.value);
1268
+ return `${BAR} ${styleText("green", "◇")} ${this.message} ${styleText(
1269
+ "dim",
1270
+ icon(chosen?.label ?? String(this.value ?? "")),
1271
+ )}`;
1272
+ }
1273
+ if (this.state === "cancel") {
1274
+ return `${BAR} ${styleText("red", "■")} cancelled`;
1275
+ }
1276
+
1277
+ const filterTag =
1278
+ this.query.length > 0
1279
+ ? styleText("dim", ` · filter: ${this.query}▏`)
1280
+ : styleText("dim", " · type to filter");
1281
+
1282
+ const active = this.selectable[this.cursor];
1283
+ const lines: string[] = [];
1284
+ lines.push(`${BAR}`);
1285
+ lines.push(`${BAR} ${styleText("cyan", "◆")} ${this.message}${filterTag}`);
1286
+
1287
+ if (this.display.length === 0) {
1288
+ lines.push(`${BAR} ${styleText("yellow", `no profiles match "${this.query}"`)}`);
1289
+ }
1290
+ // Scroll the list so the active row stays centered and the top/bottom rows
1291
+ // remain reachable instead of being clipped off-screen on a long list.
1292
+ const activeIdx = active ? this.display.indexOf(active) : 0;
1293
+ const win = windowOptions(this.display, activeIdx, this.visibleRows());
1294
+ if (win.hiddenAbove > 0) {
1295
+ lines.push(`${BAR} ${styleText("dim", `↑ ${win.hiddenAbove} more`)}`);
1296
+ }
1297
+ for (const o of win.items) {
1298
+ if (o.divider === true) {
1299
+ lines.push(`${BAR} ${styleText("dim", icon(o.label))}`);
1300
+ continue;
1301
+ }
1302
+ const isCursor = o === active;
1303
+ const bullet = isCursor ? styleText("green", "●") : styleText("dim", "○");
1304
+ const label = isCursor ? icon(o.label) : styleText("dim", icon(o.label));
1305
+ const hint = isCursor && o.hint ? styleText("dim", ` ${o.hint}`) : "";
1306
+ lines.push(`${BAR} ${bullet} ${label}${hint}`);
1307
+ }
1308
+ if (win.hiddenBelow > 0) {
1309
+ lines.push(`${BAR} ${styleText("dim", `↓ ${win.hiddenBelow} more`)}`);
1310
+ }
1311
+
1312
+ // Footer mirrors the combine screen: lead with the bright enter affordance,
1313
+ // nav keys trail dim. Enter only lights up when a row is actually
1314
+ // selectable (an empty filter result blocks submit, so don't promise it).
1315
+ // No label in the footer — the cursored row is already highlighted, and a
1316
+ // long composite-stack label would wrap the line on a narrow terminal.
1317
+ const canSelect = this.selectable.length > 0;
1318
+ const enterStyled = styleText(canSelect ? "cyan" : "dim", "⏎ enter to select");
1319
+ const navStyled = styleText("dim", " · type to filter · ↑↓ move · esc cancel");
1320
+ lines.push(`${BAR}`);
1321
+ lines.push(`${BAR} ${enterStyled}${navStyled}`);
1322
+ return lines.join("\n");
1323
+ }
246
1324
  }
247
1325
 
248
1326
  async function selectSkipDividers(
249
1327
  opts: PickerOption[],
250
1328
  message: string,
251
1329
  ): Promise<string> {
252
- // `disabled: true` makes clack render the option (gray) but skip it during
253
- // arrow/j-k navigation, so the user can't land on a divider.
254
- const result = await p.select({
255
- message,
256
- options: opts.map((o) => ({
257
- value: o.value,
258
- label: o.label,
259
- hint: o.hint,
260
- disabled: o.divider === true,
261
- })),
262
- });
263
- if (p.isCancel(result)) {
1330
+ const prompt = new FilterSelectPrompt(message, opts);
1331
+ const result = await prompt.prompt();
1332
+ if (typeof result === "symbol") {
264
1333
  p.cancel("cancelled");
265
1334
  process.exit(130);
266
1335
  }
@@ -306,85 +1375,104 @@ export async function runPicker(input: PickerInput): Promise<PickerOutput> {
306
1375
 
307
1376
  const picks: string[] = [first];
308
1377
 
309
- // Suggested companions: the picked profile's `recommends:` list, filtered to
310
- // entries that actually exist as options. Skips composite values (anything
311
- // containing `+`) they can't be stacked further. Empty selection = plain
312
- // single-profile pin. The picker no longer offers arbitrary combine; users
313
- // who want non-recommended combos can `cue use a+b+c` directly.
1378
+ // Suggested companions for the combine multiselect, drawn from three sources
1379
+ // (see buildCompanionOptions): the picked profile's `recommends:`, historical
1380
+ // pairings mined from the session log, and content-detected companions for
1381
+ // this cwd (image assets higgsfield, markdown drafts blog-writer, a
1382
+ // registered brand dir postizz). Empty result = plain single-profile pin;
1383
+ // users who want non-recommended combos can `cue use a+b+c` directly.
314
1384
  const firstOpt = input.options.find((o) => o.value === first);
315
- const recommendsRaw = firstOpt?.recommends ?? [];
316
- // Historical pair suggestions from local session log — merged into the
317
- // recommends list so the multiselect surfaces empirical partners even
318
- // when the profile author didn't think to add them to recommends.
319
- const pairSuggested = input.pairSuggestions?.get(first) ?? [];
320
- const merged: string[] = [];
321
- const seen = new Set<string>();
322
- for (const r of [...recommendsRaw, ...pairSuggested]) {
323
- if (seen.has(r)) continue;
324
- seen.add(r);
325
- merged.push(r);
326
- }
327
- const recommends = merged.filter((r) => {
328
- if (r === first) return false;
329
- const target = input.options.find((o) => o.value === r);
330
- return target !== undefined && target.divider !== true;
1385
+ const { companionOptions, initialValues, overflowOptions } = buildCompanionOptions({
1386
+ primary: first,
1387
+ primaryLabel: firstOpt?.label ?? first,
1388
+ options: input.options,
1389
+ recommends: firstOpt?.recommends ?? [],
1390
+ autoSelect: firstOpt?.autoSelect ?? [],
1391
+ pairSuggested: input.pairSuggestions?.get(first) ?? [],
1392
+ companions: input.companions ?? [],
1393
+ universalSuggestions: input.universalSuggestions ?? [],
1394
+ autoCheckThreshold: COMBINE_AUTO_CHECK_CONFIDENCE,
331
1395
  });
332
- const pairSuggestedSet = new Set(pairSuggested);
333
- if (recommends.length > 0) {
334
- // Sentinel for the first option ("skip combining"). Picking it — even
335
- // alongside others means "use the primary profile alone." Pressing
336
- // enter with nothing checked has the same effect; the explicit row is
337
- // there so users who don't realize that have a visible escape hatch.
338
- const SKIP = "__skip_combine__";
339
- const firstLabel = firstOpt?.label ?? first;
340
- const companionOptions: AsciiMSOption[] = [
341
- ...recommends
342
- .map((r) => input.options.find((o) => o.value === r)!)
343
- .map((o) => ({
344
- value: o.value,
345
- label: o.label,
346
- hint: o.hint,
347
- // Forward each option's conflict declarations so the multiselect
348
- // can disable mutually-exclusive partners (e.g. medusa-vite blocks
349
- // medusa-next once checked).
350
- conflicts: o.conflicts,
351
- })),
352
- { value: SKIP, label: `use ${firstLabel} alone`, hint: "", kind: "action" },
353
- ];
354
- // Pre-check any recommended companion whose source option has the
355
- // `preselect` flag set (cwd autodetection sets this) OR which appears
356
- // in the pair-suggestion map for the primary (historical signal). Both
357
- // sources merge into a single de-duped initialValues list. Conflict
358
- // resolution still applies at confirm time so a bad pair of preselects
359
- // can't sneak through.
360
- const initialValues = recommends
361
- .filter((r) => {
362
- if (pairSuggestedSet.has(r)) return true;
363
- return input.options.find((o) => o.value === r)?.preselect === true;
364
- })
365
- .filter((r, i, arr) => arr.indexOf(r) === i);
1396
+ if (companionOptions.length > 0) {
1397
+ // Precompute each offered profile's resources (primary + companions, small
1398
+ // N) so the live render stays synchronous: per-row "+N skills" hints and
1399
+ // the combined-total preview both read from this map. Absent resolver (or a
1400
+ // failing load) just means no preview the multiselect works regardless.
1401
+ const tallies = new Map<string, ProfileTally>();
1402
+ let preview: { primary: string; tallies: Map<string, ProfileTally> } | undefined;
1403
+ // Load tallies for a set of profile values into the shared map (deduped,
1404
+ // best-effort). Reused for the initial curated set and, lazily, for the
1405
+ // overflow list when the user expands "show all profiles".
1406
+ const loadTallies = async (values: string[]): Promise<void> => {
1407
+ if (!input.resourceTally) return;
1408
+ await Promise.all(
1409
+ values
1410
+ .filter((v) => !tallies.has(v))
1411
+ .map(async (v) => {
1412
+ try {
1413
+ tallies.set(v, await input.resourceTally!(v));
1414
+ } catch {
1415
+ /* skip this profile — it just renders without counts */
1416
+ }
1417
+ }),
1418
+ );
1419
+ };
1420
+ if (input.resourceTally) {
1421
+ const wanted = [
1422
+ first,
1423
+ ...companionOptions
1424
+ .filter((o) => o.kind !== "action" && o.kind !== "expand")
1425
+ .map((o) => o.value),
1426
+ ];
1427
+ await loadTallies(wanted);
1428
+ if (tallies.has(first)) preview = { primary: first, tallies };
1429
+ }
366
1430
  const extra = await asciiMultiselect({
367
1431
  message: `Combine ${first} with…`,
368
1432
  options: companionOptions,
369
1433
  initialValues: initialValues.length > 0 ? initialValues : undefined,
370
1434
  required: false,
1435
+ preview,
1436
+ overflow:
1437
+ overflowOptions.length > 0
1438
+ ? {
1439
+ options: overflowOptions,
1440
+ // Fill in the revealed profiles' resource counts so their rows
1441
+ // and the live preview show "+N skills" once shown.
1442
+ onReveal: () => loadTallies(overflowOptions.map((o) => o.value)),
1443
+ }
1444
+ : undefined,
371
1445
  });
372
1446
  if (p.isCancel(extra)) {
373
1447
  p.cancel("cancelled");
374
1448
  process.exit(130);
375
1449
  }
376
1450
  const selected = extra as string[];
377
- if (!selected.includes(SKIP)) {
1451
+ // The SKIP_COMBINE sentinel (the "use <primary> alone" row) means "primary
1452
+ // only" even when other rows are checked; enter-with-nothing-checked is the
1453
+ // same, the explicit row is just a visible escape hatch.
1454
+ if (!selected.includes(SKIP_COMBINE)) {
378
1455
  for (const v of selected) {
379
1456
  if (!picks.includes(v)) picks.push(v);
380
1457
  }
381
1458
  }
382
1459
  }
383
1460
 
384
- const choice = picks.join("+");
1461
+ // `first` may itself be a composite ("a+b+c"); flatten + dedupe so a profile
1462
+ // already in the composite primary — or one picked twice — can't bloat the
1463
+ // selector, the runtime dir name, or the summary breakdown.
1464
+ const choiceParts = dedupeSelectorParts(picks);
1465
+ const choice = choiceParts.join("+");
1466
+
1467
+ // Remember this combine (≥2 parts) so the same primary re-suggests it next
1468
+ // time — unchecked, as a "you paired these before" hint. Local + best-effort;
1469
+ // recordCombo no-ops on a single-profile pick and never throws.
1470
+ try {
1471
+ recordCombo(choiceParts, new Date().toISOString());
1472
+ } catch { /* logging must never block a launch */ }
385
1473
 
386
- // Build a display label with icon(s) for the outro line
387
- const pickedLabel = picks
1474
+ // Build a display label with icon(s) for the outro line, per deduped part.
1475
+ const pickedLabel = choiceParts
388
1476
  .map((pk) => input.options.find((o) => o.value === pk)?.label ?? pk)
389
1477
  .join(" + ");
390
1478