Last Updated: 2025-11-28
Status: Living Document
This document captures the UX design patterns, conventions, and best practices established during MUXI CLI development. These patterns ensure a consistent, polished, and user-friendly experience throughout the CLI.
- Validation & Error Handling
- URL & Endpoint Handling
- Menu Selections
- Message Formatting
- User Input Patterns
- Natural Language
- Ctrl+C Handling
- ID Normalization
- Secret Management
- State Management
Bad (Old Pattern):
registriesStr, err := wizard.PromptString("Registry URLs", "", nil)
if registriesStr == "" {
return fmt.Errorf("at least one registry URL is required")
// User has to restart entire wizard! ❌
}Good (New Pattern):
// Loop until valid input
for {
registriesStr, err := wizard.PromptString("Registry URLs", existingRegistries, nil)
if err != nil {
// Ctrl+C - exit gracefully
fmt.Println()
ui.Dimmed("Configuration cancelled")
return nil
}
if registriesStr == "" {
ui.PromptError("Registry URLs", registriesStr, fmt.Errorf("at least one registry URL is required"))
continue // ← Re-prompt, don't exit!
}
// Validate...
if !valid {
ui.PromptError("Registry URLs", registriesStr, fmt.Errorf("invalid format"))
continue // ← Re-prompt, don't exit!
}
ui.PromptSuccess("Registries", fmt.Sprintf("%d added", len(registries)))
break // ← Success!
}Benefits:
- ✅ Users can fix typos immediately
- ✅ No need to restart wizard
- ✅ Less frustrating UX
- ✅ Ctrl+C still works to cancel
Rule: Max 70 characters per detail line
Bad:
return fmt.Errorf("the registry URL you provided is not valid because it contains an invalid hostname format")
// ❌ Too long!Good:
// Error wrapper splits into multiple lines automatically
ui.PromptError("Registry URLs", input, fmt.Errorf("invalid URL: %s (malformed hostname)", url))
// Output:
// ✗ Registry URLs: example..com
//
// invalid URL: https://example..com (malformed hostname)Implementation:
// In ui.PromptError():
words := strings.Fields(message)
var lines []string
currentLine := " "
for _, word := range words {
if len(currentLine)+len(word)+1 > 70 {
lines = append(lines, currentLine)
currentLine = " " + word
} else {
if currentLine != " " {
currentLine += " "
}
currentLine += word
}
}Rule: Auto-add https:// if missing, reject http://
func normalizeURL(url string) (string, error) {
url = strings.TrimSpace(url)
// Reject http:// (insecure)
if strings.HasPrefix(url, "http://") {
return "", fmt.Errorf("must use https:// (http:// is not secure)")
}
// Auto-add https:// if missing
if !strings.HasPrefix(url, "https://") {
url = "https://" + url
}
// Validate format
parsed, err := neturl.Parse(url)
if err != nil || parsed.Host == "" {
return "", fmt.Errorf("invalid format")
}
// Check for malformed hostname
if strings.Contains(parsed.Host, "..") || strings.HasPrefix(parsed.Host, ".") {
return "", fmt.Errorf("malformed hostname")
}
return url, nil
}User Experience:
# Input: registry.com
# Normalized: https://registry.com ✅
# Input: https://registry.com
# Normalized: https://registry.com ✅
# Input: http://registry.com
# Error: must use https:// ❌
# Input: example..com
# Error: malformed hostname ❌Rule: Accept comma, space, OR newline separated values (but don't advertise it!)
// parseURLList parses comma, space, or line-separated URLs
func parseURLList(input string) []string {
if input == "" {
return []string{}
}
// Split by comma, space, or newline (flexible input)
parts := strings.FieldsFunc(input, func(r rune) bool {
return r == ',' || r == '\n' || r == ' '
})
var result []string
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}Prompt Text:
Registry URLs (comma or line-separated): ← Keep it simple!
Why hide space-support?
- Don't overwhelm users with "comma, space, or line-separated"
- They'll naturally try spaces and it'll "just work"
- Feels smart and forgiving
Rule: Selected option in arrow-key menus should be green + bold
func renderOptions(options []Option, selectedIndex int) {
green := color.New(color.FgGreen, color.Bold)
for i, opt := range options {
if i == selectedIndex {
// Selected: green + bold
fmt.Print(" ")
green.Print("◉ ")
green.Println(opt.Label)
} else {
// Not selected: dimmed
fmt.Printf(" \033[2m◯ %s\033[0m\n", opt.Label)
}
}
}Visual Output:
Authentication (↑↓ to select):
◉ API Key ← Green bold
○ Bearer Token ← Dimmed
○ Basic Auth ← Dimmed
○ None ← Dimmed
Rule: Use PromptSelect with arrow keys for lists with many options (e.g., LLM providers)
Bad (for 20+ options):
Select provider:
[1] OpenAI
[2] Anthropic
...
[21] Anyscale
Select (1-21): _ ← Error-prone!
Good:
Select provider (↑↓ to select, Enter to confirm):
◉ OpenAI ← Green, navigable
◯ Anthropic
◯ Google
...
Implementation:
options := []wizard.SelectOption{
{Value: "openai", Label: "OpenAI"},
{Value: "anthropic", Label: "Anthropic"},
// ...
}
selection, err := wizard.PromptSelect("Select provider", options, 0)Benefits:
- ✅ No typos (can't type wrong number)
- ✅ Faster navigation with arrow keys
- ✅ Visual feedback on current selection
- ✅ Better UX for mobile/touch terminals
Rule: If prompt text exceeds 60 chars, wrap to next line
func PromptString(prompt string, defaultValue string, options []string) (string, error) {
// Multi-line if > 60 chars
if len(prompt) > 60 {
bold := color.New(color.Bold)
bold.Println(prompt)
fmt.Print(" ") // Indent input on next line
} else {
// Single line
bold := color.New(color.Bold)
bold.Printf("%s: ", prompt)
}
// ... rest of prompt logic
}Visual Output:
# Short prompt (single line):
Registry URLs (comma or line-separated): _
# Long prompt (multi-line):
This is a very long prompt that exceeds sixty characters
_
Rule: Use natural, friendly language instead of technical terms
User-Facing Messages:
- ✅
in the formation(NOT:in formation.yaml) - ✅
added to the formation(NOT:added to formation.yaml) - ✅
to agent 'weather'(NOT:to agents/weather.yaml)
Error Messages:
- ✅
failed to read the formation(NOT:failed to read formation.yaml) - ✅
failed to update the formation(NOT:failed to update formation.yaml)
Success Messages:
// Bad
ui.Success("A2A inbound configuration added to formation.yaml")
// Good
ui.Success("A2A inbound configuration added to the formation")Exception: "Created" messages still show file paths (helpful to know where):
✓ Created agents/weather.yaml
✓ Created mcps/weather-api.yaml
Rule: Use present continuous for active processes
# Creating formation... ← "-ing" form
# Configuring A2A... ← "-ing" form
# Adding MCP... ← "-ing" form
Implementation:
ui.Banner("Configuring A2A Inbound") // ✅
ui.Banner("Configure A2A Inbound") // ❌Rule: All banners show "MUXI" in right corner, colored gold (brand color)
╭──────────────────────────────────────────────────────────────╮
│ [+] Creating new formation MUXI │ ← MUXI is gold!
╰──────────────────────────────────────────────────────────────╯
Implementation:
// Banner automatically colors "MUXI" in gold
func Banner(banner string) {
lines := strings.Split(banner, "\n")
for _, line := range lines {
if strings.Contains(line, "MUXI │") {
parts := strings.SplitN(line, "MUXI", 2)
fmt.Print(parts[0])
gold.Print("MUXI") // ← Brand color!
fmt.Println(parts[1])
} else {
fmt.Println(line)
}
}
fmt.Println()
}Rule: Show branded ASCII logo before formation wizard (main entry point)
ui.Gold(` ███╗ ███╗██╗ ██╗██╗ ██╗██╗
████╗ ████║██║ ██║╚██╗██╔╝██║
██╔████╔██║██║ ██║ ╚███╔╝ ██║
██║╚██╔╝██║██║ ██║ ██╔██╗ ██║
██║ ╚═╝ ██║╚██████╔╝██╔╝ ██╗██║
╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝`)Guidelines:
- ✅ Use
ui.Gold()for brand color (golden/orange) - ✅ Indent with 2 spaces for alignment with banners
- ✅ Only show for major entry points (
muxi new formation) - ✅ No empty line between logo and banner
Rule: When editing, show existing values as defaults
var existingRegistries string
if alreadyConfigured {
existingRegistries = extractA2ARegistries(content)
}
// Show as default
registriesStr, err := wizard.PromptString(
"Registry URLs",
existingRegistries, // ← Pre-filled!
nil,
)User Experience:
Registry URLs [https://old-registry.com]:
← Press Enter to keep existing
← Or type new value to replace
Rule: Warn + confirm before replacing existing configuration
if alreadyConfigured && !noWizard {
fmt.Println()
red := color.New(color.FgRed, color.Bold)
red.Println(" ⚠ A2A inbound is already configured in the formation")
fmt.Println()
ui.Dimmed("This will replace the entire A2A inbound configuration.")
ui.Dimmed("Existing values will be shown as defaults - press Enter to keep them.")
fmt.Println()
confirm, err := wizard.PromptString("Continue and replace? (y/N)", "", nil)
if confirm != "y" {
ui.Dimmed("Configuration cancelled")
return nil
}
}Rule: Check errors from wizard.PromptString() and exit gracefully
registriesStr, err := wizard.PromptString("Registry URLs", existingRegistries, nil)
if err != nil {
// User pressed Ctrl+C
fmt.Println()
ui.Dimmed("Configuration cancelled")
return nil // ← Clean exit
}Don't ignore errors:
// Bad
registriesStr, _ := wizard.PromptString(...) // ❌ Ctrl+C treated as empty!
// Good
registriesStr, err := wizard.PromptString(...) // ✅ Ctrl+C exits gracefully
if err != nil {
fmt.Println()
ui.Dimmed("Configuration cancelled")
return nil
}Rule: Accept human-friendly input (with spaces) and normalize to valid ID format.
// normalizeComponentName converts user input to valid component name format
func normalizeComponentName(name string) string {
// Convert to lowercase
name = strings.ToLower(name)
// Replace spaces with hyphens
name = strings.ReplaceAll(name, " ", "-")
// Replace multiple hyphens with single hyphen
for strings.Contains(name, "--") {
name = strings.ReplaceAll(name, "--", "-")
}
// Trim leading/trailing hyphens
name = strings.Trim(name, "-")
return name
}User Experience:
Service ID: External Billing Service
✓ Service ID: external-billing-service ← Normalized!
Service name [External Billing Service]: ⏎ ← Pre-filled!
✓ Service name: External Billing ServiceBenefits:
- ✅ Users can type naturally ("External Billing" not "external-billing")
- ✅ Output shows the normalized ID so user knows what was created
- ✅ Name is auto-suggested from ID (saves typing)
- ✅ Consistent IDs throughout the formation
Rule: Pre-fill the name field with title case of the ID.
// titleCase converts kebab-case to Title Case
func titleCase(s string) string {
words := strings.Split(s, "-")
for i, word := range words {
if len(word) > 0 {
words[i] = strings.ToUpper(word[:1]) + word[1:]
}
}
return strings.Join(words, " ")
}
// In wizard:
inferredName := titleCase(serviceID) // "external-billing" → "External Billing"
serviceName, err := wizard.PromptString("Service name", inferredName, nil)User Experience:
Service ID: weather-api
✓ Service ID: weather-api
Service name [Weather Api]: ⏎ ← Press Enter to accept
✓ Service name: Weather Api
Applied To:
- ✅ Agent wizard (
muxi new agent) - ✅ MCP wizard (
muxi new mcp) - ✅ A2A service wizard (
muxi new a2a-service)
Rule: When an item already exists, show its name and description for context.
// Get existing component info for context
existingName, existingDesc := getComponentInfo(agentFile)
existingInfo := formatExistingInfo(existingName, existingDesc)
ui.PromptError("Agent ID", inputName, fmt.Errorf(
"agent '%s' already exists%s\n\nChoose a different ID or edit:\n muxi edit agent %s",
name, existingInfo, name,
))User Experience:
Agent ID: some-agent
✗ Agent ID: some-agent
agent 'some-agent' already exists
→ Some Agent: Handles customer inquiries
Choose a different ID or edit:
muxi edit agent some-agent
Benefits:
- ✅ Developer sees what the existing item is without opening the file
- ✅ Helps decide: is this a duplicate or did I want a different ID?
- ✅ Description truncated to 60 chars for readability
- ✅ Suggests
muxi editcommand instead of manual file path
Applied To:
- ✅ Agent wizard (
muxi new agent) - ✅ MCP wizard (
muxi new mcp) - ✅ A2A service wizard (
muxi new a2a-service)
Rule: Show API key while typing (so user can verify), display as *** after submit
Why not hide while typing?
- Users often paste API keys and need to verify they pasted correctly
- Hidden input leads to errors ("did I paste it?")
- After submission, mask it for security in terminal scrollback
// Use regular PromptString (visible input)
apiKey, err := wizard.PromptString("OpenAI API Key", "", nil)
if err != nil {
return err
}
// Show masked after submission
if apiKey != "" {
ui.PromptSuccess("API Key", "***") // ← Masked!
} else {
ui.PromptSkipped("API Key")
}User Experience:
OpenAI API Key: sk-proj-abc123xyz789 ← Visible while typing
✓ API Key: *** ← Masked after submit
Rule: Allow skipping API key entry (can add later via secrets command)
apiKey, err := wizard.PromptString("OpenAI API Key", "", nil)
if apiKey != "" {
config.APIKey = apiKey
ui.PromptSuccess("API Key", "***")
ui.PromptSuccess("Model", "openai/gpt-5-mini")
} else {
ui.PromptSkipped("API Key")
ui.PromptSuccess("Model", "openai/gpt-5-mini (add API key later)")
}Next Steps remind user:
Next steps:
cd my-formation
muxi secrets set OPENAI_API_KEY ← Shown if key was skipped
muxi new agent
...
Rule: Don't warn about API key prefixes - too noisy and often wrong
Bad:
if !strings.HasPrefix(apiKey, "sk-") {
ui.Warning("OpenAI API keys typically start with 'sk-'") // ❌ Annoying!
}Why avoid:
- Many providers have multiple key formats
- Keys can be valid even without expected prefix
- Runtime will give clear error if key is actually invalid
- Adds noise and confuses users
Rule: Store placeholders in formation, actual values in secrets file
// In formation.yaml
auth:
type: "api_key"
key: "${{ secrets.A2A_INBOUND_API_KEY }}"
// In secrets file
A2A_INBOUND_API_KEY=
// User fills this in later (or via secrets command)Rule: Disable exits, Enable continues to wizard
Rationale:
- Disabling = "turn it off and I'm done" → Exit makes sense
- Enabling = "turn it on and let me configure it" → Continue to wizard makes sense
if isEnabled {
togglePrompt = "Disable inbound A2A? (y/N)"
} else {
togglePrompt = "Enable inbound A2A? (y/N)"
}
toggle, err := wizard.PromptString(togglePrompt, "", nil)
if toggle == "y" {
if isEnabled {
// Disable and EXIT
disableA2AInbound(rootDir)
ui.Success("A2A inbound disabled in the formation")
return nil // ← Done!
} else {
// Enable and CONTINUE to wizard
enableA2AInbound(rootDir)
ui.Success("A2A inbound enabled in the formation")
fmt.Println()
// Fall through to wizard below ← Continue!
}
}User Experience:
| Current State | User Action | Result |
|---|---|---|
| Enabled | Disable? y | Disable and EXIT |
| Enabled | Disable? n | Continue to wizard (keep enabled) |
| Disabled | Enable? y | Enable and CONTINUE to wizard ⭐ |
| Disabled | Enable? n | Continue to wizard (keep disabled) |
Benefit: One command to enable + configure!
Rule: Show appropriate warning based on current state
var isEnabled bool
if alreadyConfigured {
isEnabled = extractA2AInboundEnabled(content)
}
if isEnabled {
red.Println(" ⚠ A2A inbound is already enabled in the formation")
} else {
red.Println(" ⚠ A2A inbound is already configured in the formation")
}User sees:
already enabled→ Implies it's currently activealready configured→ Implies it exists but might be disabled
- ✅ Use validation loops, not exits
- ✅ Max 70 chars per error line
- ✅ Re-prompt on error, don't exit
- ✅ Auto-add
https:// - ✅ Reject
http:// - ✅ Accept comma/space/newline (don't advertise space)
- ✅ Green bold for selected
- ✅ Radio buttons for long lists (20+ options)
- ✅ Multi-line prompts >60 chars
- ✅ "the formation" not "formation.yaml"
- ✅ "-ing" verbs in banners
- ✅ MUXI branding in all banners (gold color)
- ✅ ASCII logo for main entry points (gold)
- ✅ Pre-fill existing values
- ✅ Confirm before replacing
- ✅ Check errors for Ctrl+C
- ✅ Accept spaces, convert to hyphens
- ✅ Lowercase, remove extra hyphens
- ✅ Auto-suggest name from ID (title case)
- ✅ Show existing item info in duplicates
- ✅ Visible while typing, masked after submit (***)
- ✅ Optional API keys (can add later)
- ✅ No prefix validation warnings
- ✅ Placeholders in formation, values in secrets file
- ✅ Disable exits
- ✅ Enable continues to wizard
- ✅ Smart state-aware prompts
This document will evolve as we establish new patterns. When adding new wizards or commands, refer to these patterns for consistency.
Questions? Check existing implementations in:
src/pkg/scaffold/components.go- A2A wizards (reference implementation)src/pkg/wizard/wizard.go- Prompt patternssrc/pkg/ui/ui.go- Banner and message formatting
Last major update: Formation wizard rewrite with LLM provider selection (2025-11-28)
Next patterns to establish: Validation command, Config commands