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
@@ -1,6 +1,40 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { renderProfileList, resolveConflicts, type PickerOption } from "./picker";
3
+ import {
4
+ buildCompanionOptions,
5
+ filterOptions,
6
+ renderProfileList,
7
+ resolveConflicts,
8
+ buildConflictMap,
9
+ combineCategoryOf,
10
+ groupByCategory,
11
+ windowOptions,
12
+ SKIP_COMBINE,
13
+ SHOW_ALL,
14
+ UNIVERSAL_COMPANIONS,
15
+ FEATURED_HINT,
16
+ FREQUENT_HINT,
17
+ UNIVERSAL_HINT,
18
+ HISTORY_HINT,
19
+ formatTallyDelta,
20
+ unionTallyCounts,
21
+ formatCombinedPreview,
22
+ asciiIconsEnabled,
23
+ stripIconIfAscii,
24
+ renderCombineFrame,
25
+ compressCombo,
26
+ displayWidth,
27
+ dedupeSelectorParts,
28
+ applyShowAllExpansion,
29
+ formatOverheadBadge,
30
+ MAX_FREQUENT_AUTOCHECK,
31
+ OVERHEAD_WARN_TOKENS,
32
+ type PickerOption,
33
+ type ProfileTally,
34
+ type AsciiMSOption,
35
+ } from "./picker";
36
+ import type { CompanionSignal } from "./companion-detect";
37
+ import type { UniversalSuggestion } from "./pair-suggestions";
4
38
 
5
39
  describe("renderProfileList", () => {
6
40
  test("formats option label and description", () => {
@@ -27,6 +61,94 @@ describe("renderProfileList", () => {
27
61
  });
28
62
  });
29
63
 
64
+ describe("filterOptions", () => {
65
+ const opts: PickerOption[] = [
66
+ { value: "default", label: "★ Default", hint: "core", top: true },
67
+ { value: "__divider_featured", label: "— Featured —", hint: "", divider: true },
68
+ { value: "studio", label: "🎨 studio", hint: "" },
69
+ { value: "secops", label: "🔒 secops", hint: "" },
70
+ { value: "slack", label: "💬 slack", hint: "" },
71
+ { value: "stripe", label: "💳 stripe", hint: "" },
72
+ { value: "growth", label: "🦜 growth", hint: "" },
73
+ { value: "webshop-google", label: "📊 webshop-google", hint: "" },
74
+ ];
75
+
76
+ test("empty query returns everything; dividers stay but are not selectable", () => {
77
+ const { display, selectable } = filterOptions(opts, "");
78
+ expect(display).toEqual(opts);
79
+ expect(selectable.some((o) => o.divider)).toBe(false);
80
+ expect(selectable).toHaveLength(7);
81
+ });
82
+
83
+ test("query filters to value-prefix matches and drops dividers", () => {
84
+ const { display, selectable } = filterOptions(opts, "s");
85
+ expect(display.map((o) => o.value)).toEqual(["studio", "secops", "slack", "stripe"]);
86
+ expect(display.some((o) => o.divider)).toBe(false);
87
+ expect(selectable).toEqual(display);
88
+ });
89
+
90
+ test("query is case-insensitive and trimmed", () => {
91
+ expect(filterOptions(opts, " ST ").display.map((o) => o.value)).toEqual([
92
+ "studio",
93
+ "stripe",
94
+ ]);
95
+ });
96
+
97
+ test("falls back to substring match when nothing starts with the query", () => {
98
+ // No value starts with "google", but webshop-google contains it.
99
+ expect(filterOptions(opts, "google").display.map((o) => o.value)).toEqual([
100
+ "webshop-google",
101
+ ]);
102
+ });
103
+
104
+ test("no match returns an empty list", () => {
105
+ expect(filterOptions(opts, "zzz").display).toHaveLength(0);
106
+ expect(filterOptions(opts, "zzz").selectable).toHaveLength(0);
107
+ });
108
+ });
109
+
110
+ describe("windowOptions", () => {
111
+ const nums = Array.from({ length: 20 }, (_, i) => i);
112
+
113
+ test("returns everything with no hidden when the list fits", () => {
114
+ const w = windowOptions(nums.slice(0, 5), 2, 10);
115
+ expect(w.items).toEqual([0, 1, 2, 3, 4]);
116
+ expect(w.hiddenAbove).toBe(0);
117
+ expect(w.hiddenBelow).toBe(0);
118
+ });
119
+
120
+ test("centers the active row in the middle of the window", () => {
121
+ const w = windowOptions(nums, 10, 7);
122
+ // window of 7 centered on index 10 → start = 10 - 3 = 7, items 7..13
123
+ expect(w.start).toBe(7);
124
+ expect(w.items).toEqual([7, 8, 9, 10, 11, 12, 13]);
125
+ expect(w.hiddenAbove).toBe(7);
126
+ expect(w.hiddenBelow).toBe(6);
127
+ });
128
+
129
+ test("pins to the top when the active row is near the start", () => {
130
+ const w = windowOptions(nums, 1, 7);
131
+ expect(w.start).toBe(0);
132
+ expect(w.hiddenAbove).toBe(0);
133
+ expect(w.items[0]).toBe(0);
134
+ });
135
+
136
+ test("pins to the bottom so the last rows stay reachable", () => {
137
+ const w = windowOptions(nums, 19, 7);
138
+ expect(w.start).toBe(13); // 20 - 7
139
+ expect(w.items[w.items.length - 1]).toBe(19);
140
+ expect(w.hiddenBelow).toBe(0);
141
+ expect(w.hiddenAbove).toBe(13);
142
+ });
143
+
144
+ test("max <= 0 degrades to the full list", () => {
145
+ const w = windowOptions(nums, 5, 0);
146
+ expect(w.items).toEqual(nums);
147
+ expect(w.hiddenAbove).toBe(0);
148
+ expect(w.hiddenBelow).toBe(0);
149
+ });
150
+ });
151
+
30
152
  describe("resolveConflicts", () => {
31
153
  const map = (pairs: ReadonlyArray<readonly [string, readonly string[]]>): Map<string, Set<string>> => {
32
154
  const m = new Map<string, Set<string>>();
@@ -72,3 +194,986 @@ describe("resolveConflicts", () => {
72
194
  expect(resolveConflicts(["a", "b"], new Map())).toEqual(["a", "b"]);
73
195
  });
74
196
  });
197
+
198
+ describe("buildCompanionOptions", () => {
199
+ const OPTS: PickerOption[] = [
200
+ { value: "postizz", label: "postizz", hint: "social", recommends: ["blog-writer", "trendradar"] },
201
+ { value: "blog-writer", label: "blog-writer", hint: "long-form" },
202
+ { value: "trendradar", label: "trendradar", hint: "trends" },
203
+ { value: "higgsfield", label: "higgsfield", hint: "image gen" },
204
+ { value: "creative-media", label: "creative-media", hint: "creative" },
205
+ { value: "__divider_x", label: "—", hint: "", divider: true },
206
+ { value: "medusa-next", label: "medusa-next", hint: "next", conflicts: ["medusa-vite"] },
207
+ { value: "medusa-vite", label: "medusa-vite", hint: "vite", conflicts: ["medusa-next"] },
208
+ ];
209
+ const sig = (profile: string, confidence: number, reason = "r"): CompanionSignal => ({
210
+ profile,
211
+ confidence,
212
+ reason,
213
+ });
214
+ const build = (args: Partial<Parameters<typeof buildCompanionOptions>[0]>) =>
215
+ buildCompanionOptions({
216
+ primary: "postizz",
217
+ primaryLabel: "postizz",
218
+ options: OPTS,
219
+ recommends: [],
220
+ pairSuggested: [],
221
+ companions: [],
222
+ autoCheckThreshold: 0.7,
223
+ ...args,
224
+ });
225
+
226
+ test("recommends become rows; a high-confidence detected companion is added and auto-checked", () => {
227
+ const { companionOptions, initialValues } = build({
228
+ recommends: ["blog-writer", "trendradar"],
229
+ companions: [sig("higgsfield", 0.85, "12 image assets")],
230
+ });
231
+ const values = companionOptions.map((o) => o.value);
232
+ expect(values).toContain("higgsfield");
233
+ expect(values).toContain("blog-writer");
234
+ // detected row shows the reason as its hint, not the profile description
235
+ expect(companionOptions.find((o) => o.value === "higgsfield")!.hint).toBe("12 image assets");
236
+ expect(initialValues).toContain("higgsfield");
237
+ });
238
+
239
+ test("a recommends-origin row carries recommended:true; a detected row does not", () => {
240
+ const { companionOptions } = build({
241
+ recommends: ["blog-writer"],
242
+ companions: [sig("higgsfield", 0.85, "12 image assets")],
243
+ });
244
+ expect(companionOptions.find((o) => o.value === "blog-writer")!.recommended).toBe(true);
245
+ expect(companionOptions.find((o) => o.value === "higgsfield")!.recommended).toBeFalsy();
246
+ });
247
+
248
+ test("autoSelect companions start checked with no detection signal", () => {
249
+ const { companionOptions, initialValues } = build({
250
+ autoSelect: ["blog-writer", "trendradar"],
251
+ companions: [], // crucially: no cwd detection
252
+ });
253
+ const values = companionOptions.map((o) => o.value);
254
+ expect(values).toContain("blog-writer");
255
+ expect(values).toContain("trendradar");
256
+ // The whole point: checked even though nothing was detected in the cwd.
257
+ expect(initialValues).toContain("blog-writer");
258
+ expect(initialValues).toContain("trendradar");
259
+ // An autoSelect row is also tagged recommended (it gets the → marker).
260
+ expect(companionOptions.find((o) => o.value === "blog-writer")!.recommended).toBe(true);
261
+ });
262
+
263
+ test("autoSelect overrides recommends origin without duplicating the row", () => {
264
+ const { companionOptions, initialValues } = build({
265
+ autoSelect: ["blog-writer"],
266
+ recommends: ["blog-writer"],
267
+ });
268
+ expect(companionOptions.filter((o) => o.value === "blog-writer")).toHaveLength(1);
269
+ expect(initialValues).toContain("blog-writer"); // autoSelect wins → checked
270
+ });
271
+
272
+ test("an autoSelect companion that conflicts with the primary is still dropped", () => {
273
+ const { companionOptions, initialValues } = buildCompanionOptions({
274
+ primary: "medusa-next",
275
+ primaryLabel: "medusa-next",
276
+ options: OPTS,
277
+ recommends: [],
278
+ autoSelect: ["medusa-vite"], // declared, but conflicts with medusa-next
279
+ pairSuggested: [],
280
+ companions: [],
281
+ autoCheckThreshold: 0.7,
282
+ });
283
+ expect(companionOptions.map((o) => o.value)).not.toContain("medusa-vite");
284
+ expect(initialValues).not.toContain("medusa-vite");
285
+ });
286
+
287
+ test("confirm-time conflict map (curated + overflow) drops two mutually-exclusive overflow profiles", () => {
288
+ // postizz conflicts with neither medusa profile, so both land in overflow.
289
+ const { companionOptions, overflowOptions } = build({ recommends: [] });
290
+ const ov = overflowOptions.map((o) => o.value);
291
+ expect(ov).toContain("medusa-next");
292
+ expect(ov).toContain("medusa-vite");
293
+ // The fix: asciiMultiselect builds the confirm-time map from curated + overflow,
294
+ // so a conflict declared only between two revealed profiles is still enforced.
295
+ const full = buildConflictMap([...companionOptions, ...overflowOptions]);
296
+ expect(resolveConflicts(["medusa-next", "medusa-vite"], full)).toEqual(["medusa-next"]);
297
+ // Regression guard: the old curated-only map (the CRITICAL bug) let BOTH survive
298
+ // into the written .cue-profile while the live UI showed the conflict blocked.
299
+ const stale = buildConflictMap(companionOptions);
300
+ expect(resolveConflicts(["medusa-next", "medusa-vite"], stale)).toEqual([
301
+ "medusa-next",
302
+ "medusa-vite",
303
+ ]);
304
+ });
305
+
306
+ test("a detected companion already in recommends is not duplicated", () => {
307
+ const { companionOptions } = build({
308
+ recommends: ["higgsfield"],
309
+ companions: [sig("higgsfield", 0.85)],
310
+ });
311
+ expect(companionOptions.filter((o) => o.value === "higgsfield")).toHaveLength(1);
312
+ });
313
+
314
+ test("a below-threshold companion surfaces but does not start checked", () => {
315
+ const { companionOptions, initialValues } = build({
316
+ companions: [sig("blog-writer", 0.6, "markdown drafts")],
317
+ });
318
+ expect(companionOptions.map((o) => o.value)).toContain("blog-writer");
319
+ expect(initialValues).not.toContain("blog-writer");
320
+ });
321
+
322
+ test("the primary itself is never offered as a companion", () => {
323
+ const { companionOptions } = build({
324
+ recommends: ["postizz", "blog-writer"],
325
+ companions: [sig("postizz", 0.9)],
326
+ });
327
+ expect(companionOptions.map((o) => o.value)).not.toContain("postizz");
328
+ });
329
+
330
+ test("a companion that conflicts with the primary is dropped (either declaration side)", () => {
331
+ const viaPrimary = buildCompanionOptions({
332
+ primary: "medusa-next",
333
+ primaryLabel: "medusa-next",
334
+ options: OPTS,
335
+ recommends: [],
336
+ pairSuggested: [],
337
+ companions: [sig("medusa-vite", 0.9)],
338
+ autoCheckThreshold: 0.7,
339
+ });
340
+ expect(viaPrimary.companionOptions.map((o) => o.value)).not.toContain("medusa-vite");
341
+ });
342
+
343
+ test("dividers and unknown names are skipped (curated rows lead, expand row trails)", () => {
344
+ const { companionOptions } = build({
345
+ recommends: ["__divider_x", "does-not-exist", "blog-writer"],
346
+ });
347
+ // Only blog-writer is a real curated row; the rest of OPTS becomes overflow,
348
+ // so a trailing SHOW_ALL expand row is appended after the curated companions.
349
+ const curated = companionOptions.filter((o) => o.kind !== "expand");
350
+ expect(curated.map((o) => o.value)).toEqual([SKIP_COMBINE, "blog-writer"]);
351
+ expect(companionOptions.at(-1)!.value).toBe(SHOW_ALL);
352
+ expect(companionOptions.at(-1)!.kind).toBe("expand");
353
+ });
354
+
355
+ test("historical pairings are offered unchecked with the 'paired before' hint", () => {
356
+ const { companionOptions, initialValues } = build({
357
+ recommends: ["blog-writer"],
358
+ pairSuggested: ["trendradar"],
359
+ });
360
+ // A remembered combo is a recommendation, never an auto-pin.
361
+ expect(companionOptions.map((o) => o.value)).toContain("trendradar");
362
+ expect(initialValues).not.toContain("trendradar");
363
+ expect(companionOptions.find((o) => o.value === "trendradar")!.hint).toBe(HISTORY_HINT);
364
+ });
365
+
366
+ test("the SKIP_COMBINE action row leads the list only when there's anything to combine", () => {
367
+ const withRows = build({ companions: [sig("higgsfield", 0.85)] });
368
+ expect(withRows.companionOptions[0]!.value).toBe(SKIP_COMBINE);
369
+ expect(withRows.companionOptions[0]!.kind).toBe("action");
370
+
371
+ // Genuinely empty: a primary that is the only selectable profile → no
372
+ // curated companions AND no overflow → no multiselect at all.
373
+ const solo = buildCompanionOptions({
374
+ primary: "only",
375
+ primaryLabel: "only",
376
+ options: [{ value: "only", label: "only", hint: "" }],
377
+ recommends: [],
378
+ pairSuggested: [],
379
+ companions: [],
380
+ autoCheckThreshold: 0.7,
381
+ });
382
+ expect(solo.companionOptions).toEqual([]);
383
+ expect(solo.initialValues).toEqual([]);
384
+ expect(solo.overflowOptions).toEqual([]);
385
+ });
386
+
387
+ // gstack is the sole pinned companion: emitted by buildUniversalSuggestions
388
+ // as the `pinned` origin (tested in pair-suggestions.test) and rendered here
389
+ // through the one universalSuggestions path — no separate picker injection.
390
+ const WITH_GSTACK: PickerOption[] = [
391
+ ...OPTS,
392
+ { value: "gstack", label: "🏭 gstack", hint: "engineering team", conflicts: ["vite"] },
393
+ { value: "vite", label: "vite", hint: "spa" },
394
+ ];
395
+ const PINNED: UniversalSuggestion[] = UNIVERSAL_COMPANIONS.map((name) => ({
396
+ name,
397
+ origin: "pinned",
398
+ }));
399
+
400
+ test("a pinned companion is offered (unchecked) under a primary that never names it", () => {
401
+ expect(UNIVERSAL_COMPANIONS).toContain("gstack");
402
+ const { companionOptions, initialValues } = buildCompanionOptions({
403
+ primary: "postizz",
404
+ primaryLabel: "postizz",
405
+ options: WITH_GSTACK,
406
+ recommends: [],
407
+ pairSuggested: [],
408
+ companions: [],
409
+ universalSuggestions: PINNED,
410
+ autoCheckThreshold: 0.7,
411
+ });
412
+ expect(companionOptions.map((o) => o.value)).toContain("gstack");
413
+ expect(initialValues).not.toContain("gstack"); // offered, never forced
414
+ // Pinned-origin rows get the UNIVERSAL_HINT tag, not the verbose profile
415
+ // description — consistent with featured/frequent rows.
416
+ expect(companionOptions.find((o) => o.value === "gstack")!.hint).toBe(UNIVERSAL_HINT);
417
+ });
418
+
419
+ test("the pinned companion is dropped when it is the primary or conflicts with it", () => {
420
+ const asPrimary = buildCompanionOptions({
421
+ primary: "gstack",
422
+ primaryLabel: "gstack",
423
+ options: WITH_GSTACK,
424
+ recommends: [],
425
+ pairSuggested: [],
426
+ companions: [],
427
+ universalSuggestions: PINNED,
428
+ autoCheckThreshold: 0.7,
429
+ });
430
+ expect(asPrimary.companionOptions.map((o) => o.value)).not.toContain("gstack");
431
+
432
+ const conflicting = buildCompanionOptions({
433
+ primary: "vite",
434
+ primaryLabel: "vite",
435
+ options: WITH_GSTACK,
436
+ recommends: [],
437
+ pairSuggested: [],
438
+ companions: [],
439
+ universalSuggestions: PINNED,
440
+ autoCheckThreshold: 0.7,
441
+ });
442
+ expect(conflicting.companionOptions.map((o) => o.value)).not.toContain("gstack");
443
+ });
444
+
445
+ test("an explicit recommend for a pinned companion is not duplicated", () => {
446
+ const { companionOptions } = buildCompanionOptions({
447
+ primary: "postizz",
448
+ primaryLabel: "postizz",
449
+ options: WITH_GSTACK,
450
+ recommends: ["gstack"],
451
+ pairSuggested: [],
452
+ companions: [],
453
+ universalSuggestions: PINNED,
454
+ autoCheckThreshold: 0.7,
455
+ });
456
+ expect(companionOptions.filter((o) => o.value === "gstack")).toHaveLength(1);
457
+ });
458
+
459
+ // Featured + frequently-used cross-profile suggestions (buildUniversalSuggestions).
460
+ test("a featured universal suggestion is offered unchecked with the featured hint", () => {
461
+ const { companionOptions, initialValues } = build({
462
+ universalSuggestions: [{ name: "creative-media", origin: "featured" }],
463
+ });
464
+ const row = companionOptions.find((o) => o.value === "creative-media");
465
+ expect(row).toBeDefined();
466
+ expect(row!.hint).toBe(FEATURED_HINT);
467
+ expect(initialValues).not.toContain("creative-media"); // offered, never forced
468
+ });
469
+
470
+ test("a frequently-used universal suggestion shows the frequency hint", () => {
471
+ const { companionOptions } = build({
472
+ universalSuggestions: [{ name: "creative-media", origin: "frequent" }],
473
+ });
474
+ expect(companionOptions.find((o) => o.value === "creative-media")!.hint).toBe(FREQUENT_HINT);
475
+ });
476
+
477
+ test("a universal suggestion already in recommends keeps its description, not the tag", () => {
478
+ const { companionOptions } = build({
479
+ recommends: ["blog-writer"],
480
+ universalSuggestions: [{ name: "blog-writer", origin: "featured" }],
481
+ });
482
+ const rows = companionOptions.filter((o) => o.value === "blog-writer");
483
+ expect(rows).toHaveLength(1);
484
+ expect(rows[0]!.hint).toBe("long-form");
485
+ });
486
+
487
+ test("a universal suggestion equal to or conflicting with the primary is dropped", () => {
488
+ const asPrimary = build({ universalSuggestions: [{ name: "postizz", origin: "featured" }] });
489
+ expect(asPrimary.companionOptions.map((o) => o.value)).not.toContain("postizz");
490
+
491
+ const conflicting = buildCompanionOptions({
492
+ primary: "medusa-next",
493
+ primaryLabel: "medusa-next",
494
+ options: OPTS,
495
+ recommends: [],
496
+ pairSuggested: [],
497
+ companions: [],
498
+ universalSuggestions: [{ name: "medusa-vite", origin: "frequent" }],
499
+ autoCheckThreshold: 0.7,
500
+ });
501
+ expect(conflicting.companionOptions.map((o) => o.value)).not.toContain("medusa-vite");
502
+ });
503
+ });
504
+
505
+ describe("buildCompanionOptions · show-all overflow", () => {
506
+ const OPTS: PickerOption[] = [
507
+ { value: "postizz", label: "postizz", hint: "social", recommends: ["blog-writer"] },
508
+ { value: "blog-writer", label: "blog-writer", hint: "long-form" },
509
+ { value: "trendradar", label: "trendradar", hint: "trends" },
510
+ { value: "higgsfield", label: "higgsfield", hint: "image gen" },
511
+ { value: "__divider_x", label: "—", hint: "", divider: true },
512
+ { value: "core+postizz", label: "composite", hint: "" },
513
+ { value: "⭐ default", label: "Default", hint: "", top: true },
514
+ { value: "medusa-next", label: "medusa-next", hint: "next", conflicts: ["medusa-vite"] },
515
+ { value: "medusa-vite", label: "medusa-vite", hint: "vite", conflicts: ["medusa-next"] },
516
+ ];
517
+ const build = (args: Partial<Parameters<typeof buildCompanionOptions>[0]>) =>
518
+ buildCompanionOptions({
519
+ primary: "postizz",
520
+ primaryLabel: "postizz",
521
+ options: OPTS,
522
+ recommends: [],
523
+ pairSuggested: [],
524
+ companions: [],
525
+ autoCheckThreshold: 0.7,
526
+ ...args,
527
+ });
528
+
529
+ test("overflow = every other selectable profile, minus curated/divider/composite/default", () => {
530
+ const { overflowOptions, companionOptions } = build({ recommends: ["blog-writer"] });
531
+ const values = overflowOptions.map((o) => o.value);
532
+ // blog-writer is curated → not in overflow; the rest of the real profiles are.
533
+ expect(values).not.toContain("blog-writer");
534
+ expect(values).toEqual(expect.arrayContaining(["trendradar", "higgsfield", "medusa-next"]));
535
+ // dividers, composites, the Default entry and the primary itself never leak in.
536
+ expect(values).not.toContain("__divider_x");
537
+ expect(values).not.toContain("core+postizz");
538
+ expect(values).not.toContain("⭐ default");
539
+ expect(values).not.toContain("postizz");
540
+ // The expand row is appended last, carrying the overflow count.
541
+ const expand = companionOptions.find((o) => o.kind === "expand")!;
542
+ expect(expand.value).toBe(SHOW_ALL);
543
+ expect(expand.expandCount).toBe(overflowOptions.length);
544
+ });
545
+
546
+ test("overflow respects conflicts with the primary", () => {
547
+ const { overflowOptions } = buildCompanionOptions({
548
+ primary: "medusa-next",
549
+ primaryLabel: "medusa-next",
550
+ options: OPTS,
551
+ recommends: [],
552
+ pairSuggested: [],
553
+ companions: [],
554
+ autoCheckThreshold: 0.7,
555
+ });
556
+ expect(overflowOptions.map((o) => o.value)).not.toContain("medusa-vite");
557
+ });
558
+
559
+ test("expand row + SKIP appear even when nothing is curated, so combine stays reachable", () => {
560
+ const { companionOptions, overflowOptions } = build({});
561
+ expect(companionOptions[0]!.value).toBe(SKIP_COMBINE);
562
+ expect(companionOptions.some((o) => o.kind === "expand")).toBe(true);
563
+ expect(overflowOptions.length).toBeGreaterThan(0);
564
+ });
565
+
566
+ test("no expand row when there is no overflow", () => {
567
+ const opts: PickerOption[] = [
568
+ { value: "postizz", label: "postizz", hint: "" },
569
+ { value: "blog-writer", label: "blog-writer", hint: "" },
570
+ ];
571
+ const { companionOptions } = buildCompanionOptions({
572
+ primary: "postizz",
573
+ primaryLabel: "postizz",
574
+ options: opts,
575
+ recommends: ["blog-writer"],
576
+ pairSuggested: [],
577
+ companions: [],
578
+ autoCheckThreshold: 0.7,
579
+ });
580
+ expect(companionOptions.some((o) => o.kind === "expand")).toBe(false);
581
+ });
582
+ });
583
+
584
+ describe("applyShowAllExpansion", () => {
585
+ const base: AsciiMSOption[] = [
586
+ { value: SKIP_COMBINE, label: "use postizz alone", hint: "", kind: "action", primaryLabel: "postizz" },
587
+ { value: "blog-writer", label: "blog-writer", hint: "long-form" },
588
+ { value: SHOW_ALL, label: "", hint: "", kind: "expand", expandCount: 2 },
589
+ ];
590
+ const overflow: AsciiMSOption[] = [
591
+ { value: "trendradar", label: "trendradar", hint: "trends" },
592
+ { value: "higgsfield", label: "higgsfield", hint: "image gen" },
593
+ ];
594
+
595
+ test("no-op when the SHOW_ALL sentinel isn't selected", () => {
596
+ const out = applyShowAllExpansion({ options: base, value: ["blog-writer"], cursor: 1, overflow });
597
+ expect(out.expanded).toBe(false);
598
+ expect(out.options).toBe(base); // unchanged reference
599
+ expect(out.value).toEqual(["blog-writer"]);
600
+ });
601
+
602
+ test("reveals overflow: drops the expand row, appends overflow, lands cursor on first revealed", () => {
603
+ // cursor was on the expand row (index 2); the toggle added SHOW_ALL to value.
604
+ const out = applyShowAllExpansion({
605
+ options: base,
606
+ value: ["blog-writer", SHOW_ALL],
607
+ cursor: 2,
608
+ overflow,
609
+ });
610
+ expect(out.expanded).toBe(true);
611
+ // SHOW_ALL row gone; overflow appended after the curated rows.
612
+ expect(out.options.map((o) => o.value)).toEqual([
613
+ SKIP_COMBINE,
614
+ "blog-writer",
615
+ "trendradar",
616
+ "higgsfield",
617
+ ]);
618
+ // sentinel stripped from the selection so it never counts as a profile.
619
+ expect(out.value).toEqual(["blog-writer"]);
620
+ // cursor lands where the expand row used to sit = first revealed profile.
621
+ expect(out.options[out.cursor]!.value).toBe("trendradar");
622
+ });
623
+
624
+ test("does not mutate the input arrays", () => {
625
+ const opts = [...base];
626
+ const val = ["blog-writer", SHOW_ALL];
627
+ applyShowAllExpansion({ options: opts, value: val, cursor: 2, overflow });
628
+ expect(opts).toEqual(base); // original option list untouched
629
+ expect(val).toEqual(["blog-writer", SHOW_ALL]); // original value untouched
630
+ });
631
+ });
632
+
633
+ describe("renderCombineFrame · show-all expand row", () => {
634
+ const strip = (s: string) => s.replace(/\[[0-9;]*m/g, "");
635
+ const options: AsciiMSOption[] = [
636
+ { value: SKIP_COMBINE, label: "use postizz alone", hint: "", kind: "action", primaryLabel: "postizz" },
637
+ { value: "blog-writer", label: "blog-writer", hint: "long-form" },
638
+ { value: SHOW_ALL, label: "", hint: "", kind: "expand", expandCount: 12 },
639
+ ];
640
+
641
+ test("renders the 'show all N profiles' row from expandCount", () => {
642
+ const out = strip(renderCombineFrame({ message: "Combine postizz with…", options, cursor: 1, selected: [], ascii: false }));
643
+ expect(out).toContain("show all 12 profiles");
644
+ });
645
+
646
+ test("expand row signals SPACE (▾ + '(space)'), not the ↩ enter glyph that would confirm", () => {
647
+ const out = strip(renderCombineFrame({ message: "m", options, cursor: 2, selected: [], ascii: false }));
648
+ expect(out).toContain("show all 12 profiles (space)");
649
+ const expandLine = out.split("\n").find((l) => l.includes("show all 12 profiles"));
650
+ expect(expandLine).toContain("▾");
651
+ // The ↩ glyph means "enter"; the expand row must not reuse it, or users press
652
+ // enter (which confirms the prompt) instead of space (which reveals).
653
+ expect(expandLine).not.toContain("↩");
654
+ });
655
+
656
+ test("the expand sentinel never counts toward the staged selection", () => {
657
+ const out = strip(
658
+ renderCombineFrame({ message: "m", options, cursor: 2, selected: ["blog-writer", SHOW_ALL], ascii: false }),
659
+ );
660
+ // one real profile staged, not two — SHOW_ALL is a control row.
661
+ expect(out).toContain("1 selected");
662
+ });
663
+ });
664
+
665
+ describe("combine-preview tallies", () => {
666
+ const tally = (over: Partial<ProfileTally> = {}): ProfileTally => ({
667
+ skills: [],
668
+ mcps: [],
669
+ plugins: [],
670
+ commands: [],
671
+ ...over,
672
+ });
673
+
674
+ describe("formatTallyDelta", () => {
675
+ test("renders only non-empty categories, with singular/plural", () => {
676
+ expect(
677
+ formatTallyDelta(tally({ skills: ["a", "b"], mcps: ["m"] })),
678
+ ).toBe("+2 skills · +1 mcp");
679
+ });
680
+
681
+ test("one of each reads singular", () => {
682
+ expect(
683
+ formatTallyDelta(tally({ skills: ["a"], mcps: ["m"], plugins: ["p"], commands: ["c"] })),
684
+ ).toBe("+1 skill · +1 mcp · +1 plugin · +1 cmd");
685
+ });
686
+
687
+ test("a profile that adds nothing is the empty string", () => {
688
+ expect(formatTallyDelta(tally())).toBe("");
689
+ });
690
+ });
691
+
692
+ describe("unionTallyCounts", () => {
693
+ test("de-dupes shared identifiers across profiles", () => {
694
+ const a = tally({ skills: ["x", "y"], mcps: ["m1"] });
695
+ const b = tally({ skills: ["y", "z"], plugins: ["pl"] });
696
+ expect(unionTallyCounts([a, b])).toEqual({ skills: 3, mcps: 1, plugins: 1, commands: 0 });
697
+ });
698
+
699
+ test("empty input yields all-zero counts", () => {
700
+ expect(unionTallyCounts([])).toEqual({ skills: 0, mcps: 0, plugins: 0, commands: 0 });
701
+ });
702
+ });
703
+
704
+ describe("formatCombinedPreview", () => {
705
+ test("shows base→combined only where the count changed", () => {
706
+ const base = { skills: 31, mcps: 1, plugins: 1, commands: 12 };
707
+ const combined = { skills: 48, mcps: 2, plugins: 2, commands: 12 };
708
+ expect(formatCombinedPreview(base, combined)).toEqual([
709
+ "skills 31→48 · mcps 1→2 · plugins 1→2 · cmds 12",
710
+ ]);
711
+ });
712
+
713
+ test("drops zero-count categories", () => {
714
+ const base = { skills: 5, mcps: 0, plugins: 0, commands: 0 };
715
+ expect(formatCombinedPreview(base, base)).toEqual(["skills 5"]);
716
+ });
717
+
718
+ test("nothing to show → empty array", () => {
719
+ const zero = { skills: 0, mcps: 0, plugins: 0, commands: 0 };
720
+ expect(formatCombinedPreview(zero, zero)).toEqual([]);
721
+ });
722
+ });
723
+ });
724
+
725
+ describe("ASCII icon fallback", () => {
726
+ describe("asciiIconsEnabled", () => {
727
+ test("explicit CUE_ASCII_ICONS opt-in wins", () => {
728
+ expect(asciiIconsEnabled({ CUE_ASCII_ICONS: "1" })).toBe(true);
729
+ expect(asciiIconsEnabled({ CUE_ASCII_ICONS: "true", LANG: "en_US.UTF-8" })).toBe(true);
730
+ });
731
+
732
+ test("a UTF-8 locale stays off (icons shown)", () => {
733
+ expect(asciiIconsEnabled({ LANG: "en_US.UTF-8" })).toBe(false);
734
+ expect(asciiIconsEnabled({ LC_ALL: "C.UTF-8" })).toBe(false);
735
+ });
736
+
737
+ test("a non-UTF-8 locale flips it on; an empty env stays off", () => {
738
+ expect(asciiIconsEnabled({ LANG: "C" })).toBe(true);
739
+ expect(asciiIconsEnabled({ LC_CTYPE: "POSIX" })).toBe(true);
740
+ expect(asciiIconsEnabled({})).toBe(false);
741
+ });
742
+ });
743
+
744
+ describe("stripIconIfAscii", () => {
745
+ test("off → label untouched", () => {
746
+ expect(stripIconIfAscii("🔺 vercel", false)).toBe("🔺 vercel");
747
+ });
748
+
749
+ test("on → leading emoji cluster (and its space) removed", () => {
750
+ expect(stripIconIfAscii("🔺 vercel", true)).toBe("vercel");
751
+ expect(stripIconIfAscii("✍️ blog-writer", true)).toBe("blog-writer");
752
+ expect(stripIconIfAscii("🏭 gstack", true)).toBe("gstack");
753
+ });
754
+
755
+ test("pure-ASCII labels pass through; all-glyph labels are preserved", () => {
756
+ expect(stripIconIfAscii("vite", true)).toBe("vite");
757
+ expect(stripIconIfAscii("日本語", true)).toBe("日本語");
758
+ });
759
+ });
760
+ });
761
+
762
+ describe("renderCombineFrame", () => {
763
+ const ids = (prefix: string, n: number) => Array.from({ length: n }, (_, i) => `${prefix}${i}`);
764
+ // gstack 31 skills / 1 mcp / 1 plugin / 2 cmds; vercel adds 17 disjoint
765
+ // skills, 1 mcp, 1 plugin, 0 cmds → union 48 / 2 / 2 / 2.
766
+ const tallies = new Map<string, ProfileTally>([
767
+ ["gstack", { skills: ids("g", 31), mcps: ["m-core"], plugins: ["pl-mem"], commands: ["a.md", "b.md"] }],
768
+ ["vercel", { skills: ids("v", 17), mcps: ["m-vercel"], plugins: ["pl-vercel"], commands: [] }],
769
+ ]);
770
+ const preview = { primary: "gstack", tallies };
771
+ const options: AsciiMSOption[] = [
772
+ { value: "vercel", label: "🔺 vercel", hint: "deploy" },
773
+ { value: SKIP_COMBINE, label: "use 🏭 gstack alone", hint: "", kind: "action", primaryLabel: "🏭 gstack" },
774
+ ];
775
+ // styleText may emit ANSI (color is on under `bun test` here); strip it so
776
+ // assertions match the visible text, not the escape codes between segments.
777
+ const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
778
+ const frame = (over: Partial<Parameters<typeof renderCombineFrame>[0]>) =>
779
+ strip(
780
+ renderCombineFrame({
781
+ message: "Combine gstack with…",
782
+ options,
783
+ cursor: 1,
784
+ selected: [],
785
+ preview,
786
+ ascii: false,
787
+ ...over,
788
+ }),
789
+ );
790
+
791
+ test("a checked companion shows its always-on contribution delta", () => {
792
+ const out = frame({ selected: ["vercel"], cursor: 0 });
793
+ expect(out).toContain("[x] 🔺 vercel");
794
+ expect(out).toContain("+17 skills · +1 mcp · +1 plugin");
795
+ });
796
+
797
+ test("staged combo: action row mirrors the combination + points at enter", () => {
798
+ const out = frame({ selected: ["vercel"], cursor: 1 });
799
+ expect(out).toContain("use 🏭 gstack + 🔺 vercel");
800
+ expect(out).toContain("↵ enter to confirm");
801
+ expect(out).not.toContain("← will skip combining");
802
+ });
803
+
804
+ test("live preview shows base→combined only where the count changed", () => {
805
+ const out = frame({ selected: ["vercel"] });
806
+ expect(out).toContain("→ skills 31→48 · mcps 1→2 · plugins 1→2 · cmds 2");
807
+ });
808
+
809
+ test("footer reports the staged count", () => {
810
+ expect(frame({ selected: ["vercel"] })).toContain("enter to continue with 1 selected");
811
+ });
812
+
813
+ test("a recommended companion (not cursored) shows the → gutter marker + tag", () => {
814
+ const recOpts: AsciiMSOption[] = [
815
+ { value: "vercel", label: "🔺 vercel", hint: "deploy", recommended: true },
816
+ { value: SKIP_COMBINE, label: "use 🏭 gstack alone", hint: "", kind: "action", primaryLabel: "🏭 gstack" },
817
+ ];
818
+ // cursor on the action row (idx 1) → the recommended companion at idx 0 is
819
+ // unfocused, so its gutter shows → and the row is tagged "recommended".
820
+ const out = frame({ options: recOpts, cursor: 1 });
821
+ expect(out).toContain("→ [ ] 🔺 vercel");
822
+ expect(out).toContain("recommended");
823
+ });
824
+
825
+ test("cursor on a recommended row shows › (not →) but keeps the tag", () => {
826
+ const recOpts: AsciiMSOption[] = [
827
+ { value: "vercel", label: "🔺 vercel", hint: "deploy", recommended: true },
828
+ { value: SKIP_COMBINE, label: "use 🏭 gstack alone", hint: "", kind: "action", primaryLabel: "🏭 gstack" },
829
+ ];
830
+ const out = frame({ options: recOpts, cursor: 0 });
831
+ expect(out).toContain("› [ ] 🔺 vercel");
832
+ expect(out).not.toContain("→ [ ] 🔺 vercel");
833
+ expect(out).toContain("recommended");
834
+ });
835
+
836
+ test("nothing staged: 'alone' label, no enter hint, no count, baseline preview", () => {
837
+ const out = frame({ selected: [], cursor: 1 });
838
+ expect(out).toContain("use 🏭 gstack alone");
839
+ expect(out).not.toContain("↵ enter to confirm");
840
+ expect(out).toContain("→ skills 31 · mcps 1 · plugins 1 · cmds 2");
841
+ // nothing staged → the footer's enter affordance carries no count
842
+ expect(out).toContain("⏎ enter to continue ·");
843
+ expect(out).not.toContain("enter to continue with");
844
+ });
845
+
846
+ test("skip row on overrides the ticks: alone label, collapsed preview, zero count", () => {
847
+ const out = frame({ selected: ["vercel", SKIP_COMBINE], cursor: 1 });
848
+ expect(out).toContain("use 🏭 gstack alone");
849
+ expect(out).toContain("← will skip combining");
850
+ expect(out).toContain("→ skills 31 · mcps 1 · plugins 1 · cmds 2");
851
+ expect(out).not.toContain("selected ·"); // ticks don't count while skipping
852
+ });
853
+
854
+ test("ASCII mode strips icons from rows and the action label", () => {
855
+ const out = frame({ selected: ["vercel"], cursor: 1, ascii: true });
856
+ expect(out).toContain("[x] vercel");
857
+ expect(out).toContain("use gstack + vercel");
858
+ expect(out).not.toContain("🔺");
859
+ expect(out).not.toContain("🏭");
860
+ });
861
+
862
+ test("a conflict-blocked row renders disabled with the blocker named", () => {
863
+ const conflictOpts: AsciiMSOption[] = [
864
+ { value: "medusa-next", label: "medusa-next", hint: "", conflicts: ["medusa-vite"] },
865
+ { value: "medusa-vite", label: "medusa-vite", hint: "", conflicts: ["medusa-next"] },
866
+ { value: SKIP_COMBINE, label: "use medusa alone", hint: "", kind: "action", primaryLabel: "medusa" },
867
+ ];
868
+ const out = strip(
869
+ renderCombineFrame({
870
+ message: "Combine medusa with…",
871
+ options: conflictOpts,
872
+ cursor: 0,
873
+ selected: ["medusa-next"],
874
+ ascii: false,
875
+ }),
876
+ );
877
+ expect(out).toContain("[x] medusa-next");
878
+ expect(out).toContain("[—] medusa-vite (conflicts with medusa-next)");
879
+ });
880
+ });
881
+
882
+ describe("displayWidth", () => {
883
+ test("ascii text counts one cell per char", () => {
884
+ expect(displayWidth("blog-writer")).toBe(11);
885
+ });
886
+
887
+ test("a leading emoji icon counts as two cells", () => {
888
+ // 📝(2) + space(1) + blog-writer(11) = 14
889
+ expect(displayWidth("📝 blog-writer")).toBe(14);
890
+ expect(displayWidth("🌱 growth")).toBe(9);
891
+ });
892
+
893
+ test("variation selectors and ZWJ add no width", () => {
894
+ // ⚡ + VS16 still measures as the base symbol's two cells, not three
895
+ expect(displayWidth("⚡️ vite")).toBe(displayWidth("⚡ vite"));
896
+ });
897
+ });
898
+
899
+ describe("compressCombo", () => {
900
+ test("≤ max parts render in full", () => {
901
+ expect(compressCombo(["a"])).toBe("a");
902
+ expect(compressCombo(["a", "b", "c"])).toBe("a + b + c");
903
+ });
904
+
905
+ test("> max parts collapse to 'first +N more'", () => {
906
+ expect(compressCombo(["a", "b", "c", "d"])).toBe("a +3 more");
907
+ expect(compressCombo(["gstack", "backend", "improver", "commerce", "core"])).toBe(
908
+ "gstack +4 more",
909
+ );
910
+ });
911
+
912
+ test("custom max threshold", () => {
913
+ expect(compressCombo(["a", "b", "c"], 2)).toBe("a +2 more");
914
+ });
915
+ });
916
+
917
+ describe("renderCombineFrame · density + compression", () => {
918
+ const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
919
+ const tallies = new Map<string, ProfileTally>([
920
+ ["gstack", { skills: Array.from({ length: 31 }, (_, i) => `g${i}`), mcps: ["m"], plugins: ["p"], commands: ["c"] }],
921
+ ["commerce", { skills: Array.from({ length: 96 }, (_, i) => `c${i}`), mcps: ["mc1", "mc2"], plugins: ["pc"], commands: ["cc"] }],
922
+ ]);
923
+ const preview = { primary: "gstack", tallies };
924
+
925
+ test("an unfocused row shows only the '+N skills' headline (no wrap)", () => {
926
+ const options: AsciiMSOption[] = [
927
+ { value: "commerce", label: "🛒 commerce", hint: "shop" },
928
+ { value: SKIP_COMBINE, label: "use gstack alone", hint: "", kind: "action", primaryLabel: "🏭 gstack" },
929
+ ];
930
+ // cursor on the action row (idx 1), so the commerce row is unfocused.
931
+ const out = strip(renderCombineFrame({ message: "m", options, cursor: 1, selected: ["commerce"], preview, ascii: false }));
932
+ expect(out).toContain("+96 skills");
933
+ expect(out).not.toContain("+2 mcps"); // breakdown hidden when unfocused
934
+ });
935
+
936
+ test("the focused row expands to the full breakdown", () => {
937
+ const options: AsciiMSOption[] = [
938
+ { value: "commerce", label: "🛒 commerce", hint: "shop" },
939
+ { value: SKIP_COMBINE, label: "use gstack alone", hint: "", kind: "action", primaryLabel: "🏭 gstack" },
940
+ ];
941
+ const out = strip(renderCombineFrame({ message: "m", options, cursor: 0, selected: ["commerce"], preview, ascii: false }));
942
+ expect(out).toContain("+96 skills · +2 mcps · +1 plugin · +1 cmd");
943
+ });
944
+
945
+ test("a long combo collapses the confirm row to 'first +N more'", () => {
946
+ const names = ["backend", "improver", "commerce", "skill-writer", "core", "vite"];
947
+ const options: AsciiMSOption[] = [
948
+ ...names.map((n) => ({ value: n, label: n, hint: "" })),
949
+ { value: SKIP_COMBINE, label: "use gstack alone", hint: "", kind: "action", primaryLabel: "🏭 gstack" },
950
+ ];
951
+ const out = strip(
952
+ renderCombineFrame({ message: "m", options, cursor: names.length, selected: names, ascii: false }),
953
+ );
954
+ expect(out).toContain("use 🏭 gstack +6 more");
955
+ expect(out).not.toContain("skill-writer +"); // not the wrapped full list
956
+ });
957
+
958
+ test("a composite primary is split so its parts count toward the fold", () => {
959
+ const options: AsciiMSOption[] = [
960
+ { value: "vite", label: "vite", hint: "" },
961
+ { value: SKIP_COMBINE, label: "use combo alone", hint: "", kind: "action", primaryLabel: "gstack + backend + core" },
962
+ ];
963
+ // primary = 3 parts + 1 companion = 4 > 3 → folds.
964
+ const out = strip(
965
+ renderCombineFrame({ message: "m", options, cursor: 1, selected: ["vite"], ascii: false }),
966
+ );
967
+ expect(out).toContain("use gstack +3 more");
968
+ });
969
+ });
970
+
971
+ describe("buildCompanionOptions · recents default-checked", () => {
972
+ const OPTS: PickerOption[] = [
973
+ { value: "postizz", label: "postizz", hint: "social" },
974
+ { value: "growth", label: "🦜 growth", hint: "growth work" },
975
+ { value: "improver", label: "🖊 improver", hint: "polish" },
976
+ ];
977
+
978
+ test("frequent ('you use often') starts checked; featured stays a suggestion", () => {
979
+ const { companionOptions, initialValues } = buildCompanionOptions({
980
+ primary: "postizz",
981
+ primaryLabel: "postizz",
982
+ options: OPTS,
983
+ recommends: [],
984
+ pairSuggested: [],
985
+ companions: [],
986
+ universalSuggestions: [
987
+ { name: "growth", origin: "frequent" },
988
+ { name: "improver", origin: "featured" },
989
+ ],
990
+ autoCheckThreshold: 0.7,
991
+ });
992
+ // both offered…
993
+ expect(companionOptions.map((o) => o.value)).toEqual(expect.arrayContaining(["growth", "improver"]));
994
+ // …but only the frequent one starts checked.
995
+ expect(initialValues).toContain("growth");
996
+ expect(initialValues).not.toContain("improver");
997
+ });
998
+
999
+ test("a profile already inside a composite primary is not offered (no duplication)", () => {
1000
+ const opts: PickerOption[] = [
1001
+ { value: "gstack", label: "🏭 gstack", hint: "" },
1002
+ { value: "higgsfield", label: "🌌 higgsfield", hint: "" },
1003
+ { value: "growth", label: "🦜 growth", hint: "" },
1004
+ ];
1005
+ const { companionOptions } = buildCompanionOptions({
1006
+ primary: "gstack+higgsfield", // composite primary
1007
+ primaryLabel: "🏭 gstack + 🌌 higgsfield",
1008
+ options: opts,
1009
+ recommends: ["higgsfield", "growth"], // higgsfield already in primary
1010
+ pairSuggested: ["gstack"], // gstack already in primary
1011
+ companions: [],
1012
+ autoCheckThreshold: 0.7,
1013
+ });
1014
+ const values = companionOptions.map((o) => o.value);
1015
+ expect(values).not.toContain("gstack"); // already in primary
1016
+ expect(values).not.toContain("higgsfield"); // already in primary
1017
+ expect(values).toContain("growth"); // genuinely new → still offered
1018
+ });
1019
+ });
1020
+
1021
+ describe("dedupeSelectorParts", () => {
1022
+ test("flattens composite picks and drops duplicates, first-seen order kept", () => {
1023
+ expect(dedupeSelectorParts(["gstack+higgsfield+postizz", "higgsfield", "postizz", "growth", "gstack"])).toEqual([
1024
+ "gstack",
1025
+ "higgsfield",
1026
+ "postizz",
1027
+ "growth",
1028
+ ]);
1029
+ });
1030
+
1031
+ test("a single profile passes through; empty parts are ignored", () => {
1032
+ expect(dedupeSelectorParts(["gstack"])).toEqual(["gstack"]);
1033
+ expect(dedupeSelectorParts(["a+", "+b", "a"])).toEqual(["a", "b"]);
1034
+ });
1035
+
1036
+ test("control sentinels never survive into the persisted selector (write-boundary backstop)", () => {
1037
+ expect(dedupeSelectorParts(["postizz", SHOW_ALL, "blog-writer"])).toEqual(["postizz", "blog-writer"]);
1038
+ expect(dedupeSelectorParts(["postizz", SKIP_COMBINE, "blog-writer"])).toEqual(["postizz", "blog-writer"]);
1039
+ expect(dedupeSelectorParts([`postizz+${SHOW_ALL}`, "blog-writer"])).toEqual(["postizz", "blog-writer"]);
1040
+ });
1041
+ });
1042
+
1043
+ describe("recents auto-check is capped (MAX_FREQUENT_AUTOCHECK)", () => {
1044
+ test("only the top N frequent rows start checked; the tail is offered unchecked", () => {
1045
+ const freq = ["f1", "f2", "f3", "f4", "f5"];
1046
+ const opts: PickerOption[] = [
1047
+ { value: "primary", label: "primary", hint: "" },
1048
+ ...freq.map((f) => ({ value: f, label: f, hint: "" })),
1049
+ ];
1050
+ const { companionOptions, initialValues } = buildCompanionOptions({
1051
+ primary: "primary",
1052
+ primaryLabel: "primary",
1053
+ options: opts,
1054
+ recommends: [],
1055
+ pairSuggested: [],
1056
+ companions: [],
1057
+ universalSuggestions: freq.map((name) => ({ name, origin: "frequent" as const })),
1058
+ autoCheckThreshold: 0.7,
1059
+ });
1060
+ // all five are offered…
1061
+ expect(companionOptions.filter((o) => freq.includes(o.value))).toHaveLength(5);
1062
+ // …but only the first MAX_FREQUENT_AUTOCHECK start checked.
1063
+ expect(initialValues).toEqual(freq.slice(0, MAX_FREQUENT_AUTOCHECK));
1064
+ expect(MAX_FREQUENT_AUTOCHECK).toBe(3);
1065
+ });
1066
+ });
1067
+
1068
+ describe("formatOverheadBadge", () => {
1069
+ test("stays empty below the warn threshold (light combos uncluttered)", () => {
1070
+ expect(formatOverheadBadge(0)).toBe("");
1071
+ expect(formatOverheadBadge(OVERHEAD_WARN_TOKENS)).toBe("");
1072
+ });
1073
+
1074
+ test("warns above the threshold with a rounded ~k figure and a band emoji", () => {
1075
+ const badge = formatOverheadBadge(32_000);
1076
+ expect(badge).toContain("⚠ heavy");
1077
+ expect(badge).toContain("~32k always-on");
1078
+ expect(badge).toContain("🔴"); // > 15k band
1079
+ });
1080
+ });
1081
+
1082
+ describe("renderCombineFrame · overhead warning + windowing", () => {
1083
+ const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
1084
+ const heavy = (n: number): ProfileTally => ({ skills: [], mcps: [], plugins: [], commands: [], alwaysOn: n });
1085
+
1086
+ test("the overhead warn line appears only when the combined cost is heavy", () => {
1087
+ const tallies = new Map<string, ProfileTally>([
1088
+ ["base", heavy(4000)],
1089
+ ["big", heavy(9000)],
1090
+ ]);
1091
+ const options: AsciiMSOption[] = [
1092
+ { value: "big", label: "big", hint: "" },
1093
+ { value: SKIP_COMBINE, label: "use base alone", hint: "", kind: "action", primaryLabel: "base" },
1094
+ ];
1095
+ const preview = { primary: "base", tallies };
1096
+ // base alone = 4000 → no warn.
1097
+ expect(strip(renderCombineFrame({ message: "m", options, cursor: 1, selected: [], preview, ascii: false }))).not.toContain("⚠ heavy");
1098
+ // base + big = 13000 → over the 10k threshold → warn.
1099
+ const out = strip(renderCombineFrame({ message: "m", options, cursor: 0, selected: ["big"], preview, ascii: false }));
1100
+ expect(out).toContain("⚠ heavy: ~13k always-on");
1101
+ });
1102
+
1103
+ test("a long companion list windows around the cursor, action row stays pinned", () => {
1104
+ const names = Array.from({ length: 12 }, (_, i) => `c${i}`);
1105
+ const options: AsciiMSOption[] = [
1106
+ { value: SKIP_COMBINE, label: "use base alone", hint: "", kind: "action", primaryLabel: "base" },
1107
+ ...names.map((n) => ({ value: n, label: n, hint: "" })),
1108
+ ];
1109
+ // cursor on c6 (option index 7); window of 4 companion rows.
1110
+ const out = strip(renderCombineFrame({ message: "m", options, cursor: 7, selected: [], ascii: false, maxRows: 4 }));
1111
+ expect(out).toContain("use base alone"); // action row always rendered
1112
+ expect(out).toContain("↑"); // hidden-above marker
1113
+ expect(out).toContain("more");
1114
+ expect(out).toContain("c6"); // the focused row is in-window
1115
+ expect(out).not.toContain("c0"); // scrolled off the top
1116
+ // only ~4 companion rows render (+ markers), not all 12
1117
+ const companionLines = out.split("\n").filter((l) => /\bc\d+\b/.test(l));
1118
+ expect(companionLines.length).toBeLessThanOrEqual(4);
1119
+ });
1120
+
1121
+ test("no window when maxRows is unset (every companion shows)", () => {
1122
+ const names = Array.from({ length: 12 }, (_, i) => `c${i}`);
1123
+ const options: AsciiMSOption[] = [
1124
+ { value: SKIP_COMBINE, label: "use base alone", hint: "", kind: "action", primaryLabel: "base" },
1125
+ ...names.map((n) => ({ value: n, label: n, hint: "" })),
1126
+ ];
1127
+ const out = strip(renderCombineFrame({ message: "m", options, cursor: 1, selected: [], ascii: false }));
1128
+ expect(out).toContain("c0");
1129
+ expect(out).toContain("c11");
1130
+ expect(out).not.toContain("more");
1131
+ });
1132
+ });
1133
+
1134
+ describe("combine category grouping (cue-combine design)", () => {
1135
+ const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
1136
+
1137
+ test("combineCategoryOf buckets known profiles; unknown -> other", () => {
1138
+ expect(combineCategoryOf("growth")).toBe("orchestrators");
1139
+ expect(combineCategoryOf("vercel")).toBe("backend & infra");
1140
+ expect(combineCategoryOf("stripe")).toBe("commerce");
1141
+ expect(combineCategoryOf("slack")).toBe("integrations");
1142
+ expect(combineCategoryOf("totally-unknown-xyz")).toBe("other");
1143
+ });
1144
+
1145
+ test("groupByCategory sorts profiles by category; action leads, expand trails", () => {
1146
+ const opts: AsciiMSOption[] = [
1147
+ { value: SKIP_COMBINE, label: "alone", kind: "action" },
1148
+ { value: "stripe", label: "stripe" }, // commerce
1149
+ { value: "growth", label: "growth" }, // orchestrators
1150
+ { value: "vercel", label: "vercel" }, // backend & infra
1151
+ { value: SHOW_ALL, label: "", kind: "expand", expandCount: 1 },
1152
+ ];
1153
+ const out = groupByCategory(opts);
1154
+ expect(out[0]!.value).toBe(SKIP_COMBINE);
1155
+ expect(out[out.length - 1]!.value).toBe(SHOW_ALL);
1156
+ const profiles = out.filter((o) => !o.kind).map((o) => o.value);
1157
+ expect(profiles).toEqual(["growth", "vercel", "stripe"]); // orchestrators < backend < commerce
1158
+ expect(out.find((o) => o.value === "growth")!.category).toBe("orchestrators");
1159
+ });
1160
+
1161
+ test("renderCombineFrame prints a category header with the group count", () => {
1162
+ const opts = groupByCategory([
1163
+ { value: "growth", label: "growth" },
1164
+ { value: "builder", label: "builder" },
1165
+ { value: "vercel", label: "vercel" },
1166
+ ]);
1167
+ const out = strip(renderCombineFrame({ message: "m", options: opts, cursor: 0, selected: [], ascii: false }));
1168
+ expect(out).toContain("orchestrators");
1169
+ expect(out).toContain("backend & infra");
1170
+ // group count: 2 orchestrators (growth, builder), 1 backend (vercel)
1171
+ expect(out).toMatch(/orchestrators ─+ 2/);
1172
+ });
1173
+
1174
+ test("a danger profile renders its red tag (full · never use this)", () => {
1175
+ const opts = groupByCategory([{ value: "full", label: "🦄 full", danger: "never use this" }]);
1176
+ const out = strip(renderCombineFrame({ message: "m", options: opts, cursor: 0, selected: [], ascii: false }));
1177
+ expect(out).toContain("never use this");
1178
+ });
1179
+ });