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.
- package/CHANGELOG.md +40 -0
- package/README.md +82 -33
- package/bin/cue-review-progress +107 -0
- package/bin/cue-review-watch +98 -0
- package/dist/cue.js +7352 -3744
- package/package.json +16 -5
- package/profiles/_types.ts +9 -0
- package/profiles/backend/profile.yaml +2 -0
- package/profiles/blog-writer/profile.yaml +10 -0
- package/profiles/browser/profile.yaml +9 -2
- package/profiles/builder/profile.yaml +3 -6
- package/profiles/career/profile.yaml +13 -2
- package/profiles/claude-api/profile.yaml +1 -1
- package/profiles/commerce/profile.yaml +27 -3
- package/profiles/core/logo.png +0 -0
- package/profiles/core/profile.yaml +62 -2
- package/profiles/dash-merge-test/profile.yaml +109 -0
- package/profiles/designer/profile.yaml +2 -0
- package/profiles/designer-medusa-next/profile.yaml +4 -1
- package/profiles/designer-medusa-vite/profile.yaml +4 -1
- package/profiles/docs-writer/profile.yaml +3 -1
- package/profiles/eu-tender-research/README.md +48 -0
- package/profiles/eu-tender-research/logo.png +0 -0
- package/profiles/eu-tender-research/profile.yaml +108 -0
- package/profiles/finance/logo.png +0 -0
- package/profiles/finance/profile.yaml +46 -0
- package/profiles/frontend/profile.yaml +5 -9
- package/profiles/growth/profile.yaml +2 -3
- package/profiles/gstack/profile.yaml +15 -0
- package/profiles/higgsfield/profile.yaml +3 -0
- package/profiles/hyperframes/logo.png +0 -0
- package/profiles/hyperframes/profile.yaml +59 -0
- package/profiles/improver/profile.yaml +88 -0
- package/profiles/marketing/profile.yaml +0 -3
- package/profiles/medusa-dev/profile.yaml +2 -0
- package/profiles/medusa-next/profile.yaml +2 -3
- package/profiles/medusa-vite/profile.yaml +2 -3
- package/profiles/n8n/logo.png +0 -0
- package/profiles/n8n/profile.yaml +50 -0
- package/profiles/nextjs/profile.yaml +2 -3
- package/profiles/ops/profile.yaml +2 -0
- package/profiles/postizz/profile.yaml +13 -3
- package/profiles/python/profile.yaml +3 -0
- package/profiles/research/profile.yaml +3 -1
- package/profiles/schema.json +10 -0
- package/profiles/secops/profile.yaml +2 -0
- package/profiles/seo/profile.yaml +56 -0
- package/profiles/skill-writer/profile.yaml +8 -0
- package/profiles/ssh/profile.yaml +32 -0
- package/profiles/strapi/logo.png +0 -0
- package/profiles/strapi/profile.yaml +45 -0
- package/profiles/stripe/logo.png +0 -0
- package/profiles/stripe/profile.yaml +1 -0
- package/profiles/supabase/logo.png +0 -0
- package/profiles/supabase/profile.yaml +85 -0
- package/profiles/vercel/logo.png +0 -0
- package/profiles/vercel/profile.yaml +25 -1
- package/profiles/vite/profile.yaml +4 -3
- package/profiles/web-frontend-base/profile.yaml +5 -4
- package/profiles/webshop/profile.yaml +23 -5
- package/profiles/x-growth-bot/profile.yaml +44 -0
- package/resources/icons/generate-icons.py +128 -2
- package/resources/mcps/configs/claude.sanitized.json +42 -0
- package/resources/mcps/configs/codex.sanitized.json +7 -0
- package/resources/skills/skills/career/resume-version-manager/SKILL.md +351 -0
- package/resources/skills/skills/career/salary-negotiation-prep/SKILL.md +378 -0
- package/resources/skills/skills/content/pdf/SKILL.md +2 -0
- package/resources/skills/skills/content/postiz-cards/SKILL.md +48 -0
- package/resources/skills/skills/content/postiz-cards/scripts/analytics.sh +38 -0
- package/resources/skills/skills/content/postiz-cards/scripts/card.sh +42 -0
- package/resources/skills/skills/content/postiz-cards/scripts/lint.py +38 -0
- package/resources/skills/skills/design/headless-gif-demo/SKILL.md +1 -1
- package/resources/skills/skills/design/readme-svg-design/SKILL.md +1 -1
- package/resources/skills/skills/eu-funding/grant-outreach/SKILL.md +70 -0
- package/resources/skills/skills/eu-funding/hu-grant-finder/SKILL.md +114 -0
- package/resources/skills/skills/eu-funding/hu-grant-finder/evals.md +26 -0
- package/resources/skills/skills/eu-funding/ted-tender-search/SKILL.md +80 -0
- package/resources/skills/skills/eu-funding/ted-tender-search/evals.md +26 -0
- package/resources/skills/skills/eu-funding/ted-tender-search/scripts/ted-search.sh +46 -0
- package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +1 -1
- package/resources/skills/skills/github/gx-agents/SKILL.md +96 -0
- package/resources/skills/skills/gstack/design-shotgun/SKILL.md +1 -1
- package/resources/skills/skills/marketing/ab-test-analyzer/SKILL.md +1 -1
- package/resources/skills/skills/marketing/ab-test-setup-and-analysis/SKILL.md +1 -1
- package/resources/skills/skills/marketing/account-structure-review/SKILL.md +1 -1
- package/resources/skills/skills/marketing/ad-copy-variant-generator/SKILL.md +1 -1
- package/resources/skills/skills/marketing/ad-extension-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/ad-spend-allocator/SKILL.md +1 -1
- package/resources/skills/skills/marketing/anomaly-detection/SKILL.md +1 -1
- package/resources/skills/skills/marketing/attribution-model-comparison/SKILL.md +1 -1
- package/resources/skills/skills/marketing/audience-overlap-analysis/SKILL.md +7 -1
- package/resources/skills/skills/marketing/bid-strategy-recommendations/SKILL.md +7 -1
- package/resources/skills/skills/marketing/budget-scenario-planner/SKILL.md +6 -1
- package/resources/skills/skills/marketing/campaign-naming-convention-builder/SKILL.md +7 -1
- package/resources/skills/skills/marketing/channel-mix-optimizer/SKILL.md +7 -1
- package/resources/skills/skills/marketing/client-report-narratives/SKILL.md +6 -1
- package/resources/skills/skills/marketing/competitor-creative-analysis/SKILL.md +1 -1
- package/resources/skills/skills/marketing/competitor-teardown/SKILL.md +1 -1
- package/resources/skills/skills/marketing/content-repurposer/SKILL.md +1 -1
- package/resources/skills/skills/marketing/conversion-path-analysis/SKILL.md +1 -1
- package/resources/skills/skills/marketing/cpa-diagnostics/SKILL.md +1 -1
- package/resources/skills/skills/marketing/creative-fatigue-detection/SKILL.md +1 -1
- package/resources/skills/skills/marketing/day-hour-performance-breakdown/SKILL.md +1 -1
- package/resources/skills/skills/marketing/device-performance-split/SKILL.md +1 -1
- package/resources/skills/skills/marketing/e2e-seo-assistant/SKILL.md +1 -1
- package/resources/skills/skills/marketing/email-sequence-writer/SKILL.md +1 -1
- package/resources/skills/skills/marketing/frequency-cap-recommendations/SKILL.md +1 -1
- package/resources/skills/skills/marketing/geo-performance-analysis/SKILL.md +1 -1
- package/resources/skills/skills/marketing/google-ads-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/icp-research-assistant/SKILL.md +1 -1
- package/resources/skills/skills/marketing/keyword-cannibalization-check/SKILL.md +1 -1
- package/resources/skills/skills/marketing/landing-page-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/landing-page-audit-quick/SKILL.md +1 -1
- package/resources/skills/skills/marketing/linkedin-ads-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/meta-ads-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/pacing-monitor/SKILL.md +1 -1
- package/resources/skills/skills/marketing/performance-benchmarking/SKILL.md +1 -1
- package/resources/skills/skills/marketing/programmatic-seo-builder/SKILL.md +1 -1
- package/resources/skills/skills/marketing/quality-score-breakdown/SKILL.md +1 -1
- package/resources/skills/skills/marketing/reddit-ads-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/retargeting-window-analysis/SKILL.md +1 -1
- package/resources/skills/skills/marketing/roas-forecasting/SKILL.md +1 -1
- package/resources/skills/skills/marketing/search-term-mining/SKILL.md +1 -1
- package/resources/skills/skills/marketing/utm-tracking-generator/SKILL.md +1 -1
- package/resources/skills/skills/marketing/wasted-spend-finder/SKILL.md +1 -1
- package/resources/skills/skills/marketing/weekly-account-summary/SKILL.md +1 -1
- package/resources/skills/skills/meta/awesome-list-submit/SKILL.md +4 -4
- package/resources/skills/skills/meta/cue-dashboard/SKILL.md +109 -0
- package/resources/skills/skills/meta/cue-developer/SKILL.md +161 -0
- package/resources/skills/skills/meta/cue-developer/evals/evals.json +57 -0
- package/resources/skills/skills/meta/cue-developer/references/architecture.md +65 -0
- package/resources/skills/skills/meta/cue-developer/references/build_and_test.md +72 -0
- package/resources/skills/skills/meta/cue-developer/references/contributing.md +75 -0
- package/resources/skills/skills/meta/cue-developer/references/conventions.md +57 -0
- package/resources/skills/skills/meta/cue-developer/references/first_time_setup.md +51 -0
- package/resources/skills/skills/meta/cue-developer/references/skill_and_mcp_authoring.md +84 -0
- package/resources/skills/skills/meta/cue-developer/references/troubleshooting.md +42 -0
- package/resources/skills/skills/meta/delegation-check/SKILL.md +148 -0
- package/resources/skills/skills/meta/delegation-check/specs/scan-algorithm.md +125 -0
- package/resources/skills/skills/meta/delegation-check/specs/separation-rules.md +190 -0
- package/resources/skills/skills/meta/focus/SKILL.md +62 -0
- package/resources/skills/skills/meta/help/SKILL.md +1 -1
- package/resources/skills/skills/meta/integrity-tags/SKILL.md +2 -0
- package/resources/skills/skills/meta/next-steps/SKILL.md +124 -0
- package/resources/skills/skills/meta/next-steps/evals/eval-set.json +92 -0
- package/resources/skills/skills/meta/profile-from-docs/SKILL.md +141 -0
- package/resources/skills/skills/meta/ralph-loop/SKILL.md +83 -0
- package/resources/skills/skills/meta/ralph-loop/scripts/loop.sh +73 -0
- package/resources/skills/skills/meta/skill-simplify/SKILL.md +136 -0
- package/resources/skills/skills/meta/skill-simplify/phases/01-analysis.md +173 -0
- package/resources/skills/skills/meta/skill-simplify/phases/02-optimize.md +104 -0
- package/resources/skills/skills/meta/skill-simplify/phases/03-check.md +145 -0
- package/resources/skills/skills/meta/smart-loader/scripts/smart-lookup.sh +13 -4
- package/resources/skills/skills/meta/verify-council/SKILL.md +182 -0
- package/resources/skills/skills/meta/verify-council/references/lane-prompts.md +103 -0
- package/resources/skills/skills/meta/verify-council/references/workflow.js +217 -0
- package/resources/skills/skills/nvidia/aiq-research/SKILL.md +1 -1
- package/resources/skills/skills/nvidia/cuopt-developer/SKILL.md +16 -1
- package/resources/skills/skills/nvidia/cuopt-developer/resources/contributing.md +2 -2
- package/resources/skills/skills/nvidia/cuopt-developer/resources/numerical_debugging.md +128 -0
- package/resources/skills/skills/nvidia/cuopt-developer/resources/python_bindings.md +2 -9
- package/resources/skills/skills/nvidia/cuopt-developer/resources/vrp_skills.md +166 -0
- package/resources/skills/skills/nvidia/cuopt-install/SKILL.md +2 -10
- package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-c/SKILL.md +3 -23
- package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-c/resources/examples.md +40 -20
- package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-python/SKILL.md +5 -1
- package/resources/skills/skills/nvidia/skill-evolution/SKILL.md +4 -5
- package/resources/skills/skills/research/trendradar/SKILL.md +1 -1
- package/resources/skills/skills/ssh/ssh-config/SKILL.md +94 -0
- package/resources/skills/skills/ssh/ssh-copy/SKILL.md +92 -0
- package/resources/skills/skills/ssh/ssh-harden/SKILL.md +108 -0
- package/resources/skills/skills/ssh/ssh-keys/SKILL.md +82 -0
- package/resources/skills/skills/ssh/ssh-paste-image/LICENSE +28 -0
- package/resources/skills/skills/ssh/ssh-paste-image/SKILL.md +149 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/build.sh +29 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/client/go.mod +3 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/client/main.go +79 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/ccimgd.service +12 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/com.ccimgd.plist +20 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/go.mod +3 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/main.go +98 -0
- package/resources/skills/skills/ssh/ssh-tunnel/SKILL.md +96 -0
- package/resources/skills/skills/strapi/building-with-strapi/SKILL.md +112 -0
- package/resources/skills/skills/strapi/strapi-cli/SKILL.md +93 -0
- package/resources/skills/skills/strapi/strapi-content-api/SKILL.md +115 -0
- package/resources/skills/skills/strapi/strapi-deploy/SKILL.md +89 -0
- package/resources/skills/skills/strapi/strapi-mcp-setup/SKILL.md +101 -0
- package/resources/skills/skills/strapi/strapi-plugins/SKILL.md +97 -0
- package/resources/skills/skills/tools/context7/SKILL.md +101 -0
- package/resources/skills/skills/tools/opensrc/SKILL.md +1 -1
- package/resources/skills/skills/tools/portless/SKILL.md +186 -0
- package/resources/skills/skills/xbot/operate/SKILL.md +229 -0
- package/src/commands/_index.ts +8 -0
- package/src/commands/ai-score.e2e.test.ts +11 -4
- package/src/commands/ai.ts +3 -4
- package/src/commands/auto-detect.ts +1 -1
- package/src/commands/cli.test.ts +1 -2
- package/src/commands/cli.ts +1 -1
- package/src/commands/cloud.ts +1 -1
- package/src/commands/current.ts +1 -4
- package/src/commands/dash.test.ts +110 -0
- package/src/commands/dash.ts +194 -0
- package/src/commands/dashboard.ts +26 -0
- package/src/commands/diff.ts +1 -1
- package/src/commands/discover.test.ts +1 -1
- package/src/commands/discover.ts +90 -40
- package/src/commands/doctor.test.ts +58 -0
- package/src/commands/doctor.ts +79 -3
- package/src/commands/eval-behavior.ts +1 -1
- package/src/commands/eval.ts +2 -2
- package/src/commands/evolve.ts +4 -3
- package/src/commands/failures.test.ts +1 -1
- package/src/commands/features-batch1.test.ts +6 -1
- package/src/commands/icon.ts +1 -5
- package/src/commands/import-profile.ts +1 -1
- package/src/commands/init.ts +50 -7
- package/src/commands/install-sh.e2e.test.ts +65 -0
- package/src/commands/launch-handoff.e2e.test.ts +88 -0
- package/src/commands/launch.e2e.test.ts +8 -1
- package/src/commands/launch.test.ts +29 -0
- package/src/commands/launch.ts +185 -131
- package/src/commands/lock.ts +0 -1
- package/src/commands/marketplace.ts +0 -4
- package/src/commands/materialize.ts +1 -1
- package/src/commands/mem.ts +341 -0
- package/src/commands/optimizer.ts +0 -3
- package/src/commands/playground.ts +1 -2
- package/src/commands/profile-draft-skill.ts +1 -1
- package/src/commands/replay-whatif.ts +1 -6
- package/src/commands/score.ts +2 -2
- package/src/commands/security.test.ts +88 -0
- package/src/commands/security.ts +74 -28
- package/src/commands/shell.test.ts +65 -4
- package/src/commands/shell.ts +67 -7
- package/src/commands/skills-test.ts +0 -1
- package/src/commands/skills.ts +28 -2
- package/src/commands/sources.ts +1 -2
- package/src/commands/status.ts +2 -6
- package/src/commands/submit-profile.ts +1 -1
- package/src/commands/suggest.ts +35 -10
- package/src/commands/trigger-gaps.test.ts +50 -0
- package/src/commands/trigger-gaps.ts +63 -29
- package/src/commands/update.ts +1 -1
- package/src/commands/validate.ts +16 -4
- package/src/commands/watch-live.ts +1 -1
- package/src/commands/workspace.ts +1 -1
- package/src/index.ts +26 -10
- package/src/lib/active-sessions.ts +1 -1
- package/src/lib/agent-adapters.test.ts +100 -0
- package/src/lib/agent-adapters.ts +2 -2
- package/src/lib/analytics.test.ts +88 -0
- package/src/lib/analytics.ts +82 -1
- package/src/lib/auto-detect.test.ts +10 -4
- package/src/lib/auto-detect.ts +19 -23
- package/src/lib/brand-icons.ts +0 -1
- package/src/lib/cache.ts +2 -3
- package/src/lib/claude-mem-env.test.ts +148 -0
- package/src/lib/claude-mem-env.ts +172 -0
- package/src/lib/combo-history.test.ts +53 -0
- package/src/lib/combo-history.ts +83 -0
- package/src/lib/companion-detect.test.ts +108 -0
- package/src/lib/companion-detect.ts +140 -0
- package/src/lib/companion-fetch.ts +4 -6
- package/src/lib/conditional-skills.test.ts +1 -1
- package/src/lib/config-paths.test.ts +53 -0
- package/src/lib/config-paths.ts +33 -0
- package/src/lib/dashboard-server.test.ts +351 -0
- package/src/lib/dashboard-server.ts +1476 -27
- package/src/lib/debug-log.test.ts +66 -0
- package/src/lib/debug-log.ts +45 -0
- package/src/lib/mcp-catalog.test.ts +102 -0
- package/src/lib/mcp-catalog.ts +193 -0
- package/src/lib/pair-suggestions.test.ts +111 -0
- package/src/lib/pair-suggestions.ts +98 -5
- package/src/lib/permissions.test.ts +76 -0
- package/src/lib/permissions.ts +125 -0
- package/src/lib/picker.test.ts +1106 -1
- package/src/lib/picker.ts +1230 -142
- package/src/lib/plugin-discovery.ts +126 -0
- package/src/lib/pr-poster.ts +1 -1
- package/src/lib/pr-throttle.ts +2 -6
- package/src/lib/profile-linter.test.ts +67 -1
- package/src/lib/profile-linter.ts +59 -14
- package/src/lib/profile-loader.test.ts +21 -0
- package/src/lib/profile-loader.ts +22 -3
- package/src/lib/profile-metrics.ts +2 -6
- package/src/lib/profile-names.test.ts +58 -0
- package/src/lib/repos.test.ts +57 -0
- package/src/lib/repos.ts +167 -0
- package/src/lib/resolver-npx.ts +10 -1
- package/src/lib/runtime-materializer.test.ts +200 -3
- package/src/lib/runtime-materializer.ts +129 -20
- package/src/lib/shared-profiles.ts +2 -3
- package/src/lib/skill-clis.test.ts +113 -0
- package/src/lib/skill-clis.ts +232 -0
- package/src/lib/skill-dependencies.ts +9 -1
- package/src/lib/skill-deps.ts +1 -1
- package/src/lib/skill-linter.ts +1 -1
- package/src/lib/skill-quality.ts +0 -1
- package/src/lib/skill-sandbox.test.ts +1 -1
- package/src/lib/skills-lock.test.ts +1 -1
- package/src/lib/telemetry-consent.ts +3 -5
- package/src/lib/telemetry-report.test.ts +2 -2
- package/src/lib/token-budget.ts +111 -0
- package/src/lib/trigger-gaps.test.ts +70 -0
- package/src/lib/trigger-gaps.ts +48 -6
- package/src/lib/tui/data.ts +1 -5
- package/src/lib/workflow-store.ts +150 -0
- package/src/lib/workspace-secrets.ts +0 -4
- 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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
243
|
-
// always receive a conflict-free list
|
|
244
|
-
// prompt's internal value contained.
|
|
245
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|