Skip to content

Latest commit

 

History

History
856 lines (658 loc) · 21.2 KB

File metadata and controls

856 lines (658 loc) · 21.2 KB

MUXI CLI - UX Design Patterns

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.


Table of Contents

  1. Validation & Error Handling
  2. URL & Endpoint Handling
  3. Menu Selections
  4. Message Formatting
  5. User Input Patterns
  6. Natural Language
  7. Ctrl+C Handling
  8. ID Normalization
  9. Secret Management
  10. State Management

Validation & Error Handling

Pattern: Validation Loops (Not Exits)

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

📏 Pattern: Error Message Line Length

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
    }
}

URL & Endpoint Handling

🔗 Pattern: Auto-Normalize URLs

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 ❌

🎯 Pattern: Flexible Input Parsing

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

Menu Selections

🎨 Pattern: Green Bold Highlighting

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

📻 Pattern: Radio Button Selection for Long Lists

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

📋 Pattern: Multi-Line Prompts

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
  _

Message Formatting

💬 Pattern: Natural Language

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

🎯 Pattern: Banner Verbs in "-ing" Form

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")    // ❌

🏷️ Pattern: MUXI Branding

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()
}

🎨 Pattern: ASCII Logo for Main Entry Points

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

User Input Patterns

⌨️ Pattern: Pre-Fill Existing Values

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

⚠️ Pattern: Confirmation Before Replacing

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
    }
}

Ctrl+C Handling

🛑 Pattern: Graceful Exit at All Prompts

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
}

ID Normalization

🆔 Pattern: Accept Spaces, Convert to Kebab-Case

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 Service

Benefits:

  • ✅ 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

📛 Pattern: Auto-Suggest Name from ID

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)

📋 Pattern: Show Existing Item Info in Duplicates

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 edit command instead of manual file path

Applied To:

  • ✅ Agent wizard (muxi new agent)
  • ✅ MCP wizard (muxi new mcp)
  • ✅ A2A service wizard (muxi new a2a-service)

Secret Management

🔐 Pattern: Visible Input, Masked After Submit

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

🔑 Pattern: Optional API Keys

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
  ...

🚫 Pattern: No API Key Prefix Validation

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

📝 Pattern: Secret Placeholders

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)

State Management

🔄 Pattern: Asymmetric Enable/Disable Flow

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!


📊 Pattern: Smart State Detection

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 active
  • already configured → Implies it exists but might be disabled

Summary: Quick Reference

Validation

  • ✅ Use validation loops, not exits
  • ✅ Max 70 chars per error line
  • ✅ Re-prompt on error, don't exit

URLs

  • ✅ Auto-add https://
  • ✅ Reject http://
  • ✅ Accept comma/space/newline (don't advertise space)

Selections

  • ✅ Green bold for selected
  • ✅ Radio buttons for long lists (20+ options)
  • ✅ Multi-line prompts >60 chars

Language

  • ✅ "the formation" not "formation.yaml"
  • ✅ "-ing" verbs in banners
  • ✅ MUXI branding in all banners (gold color)
  • ✅ ASCII logo for main entry points (gold)

Input

  • ✅ Pre-fill existing values
  • ✅ Confirm before replacing
  • ✅ Check errors for Ctrl+C

ID Normalization

  • ✅ Accept spaces, convert to hyphens
  • ✅ Lowercase, remove extra hyphens
  • ✅ Auto-suggest name from ID (title case)
  • ✅ Show existing item info in duplicates

Secrets

  • ✅ Visible while typing, masked after submit (***)
  • ✅ Optional API keys (can add later)
  • ✅ No prefix validation warnings
  • ✅ Placeholders in formation, values in secrets file

State

  • ✅ Disable exits
  • ✅ Enable continues to wizard
  • ✅ Smart state-aware prompts

Evolution

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 patterns
  • src/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