Skip to main content
Glama
command_autocomplete.go15.7 kB
package tui import ( "fmt" "sort" "strings" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/standardbeagle/brummer/internal/process" ) // CommandAutocomplete handles multi-level command completion type CommandAutocomplete struct { input textinput.Model segments []string // Parsed command segments currentIndex int // Which segment we're editing suggestions []string // Current suggestions selected int // Selected suggestion index showDropdown bool width int errorMessage string // Error message to display arbitraryMode bool // True when in arbitrary command mode (Ctrl+N) // Command-specific data availableScripts map[string]string // Script name -> script command processMgr *process.Manager // Reference to process manager to check running scripts aiProviders []string // Available AI provider names } func NewCommandAutocomplete(scripts map[string]string) CommandAutocomplete { ti := textinput.New() ti.Placeholder = "" ti.Prompt = "/" ti.Focus() ti.CharLimit = 100 c := CommandAutocomplete{ input: ti, availableScripts: scripts, showDropdown: true, } // Initialize suggestions c.updateSuggestions() return c } // NewCommandAutocompleteWithProcessManager creates a command autocomplete with process manager func NewCommandAutocompleteWithProcessManager(scripts map[string]string, processMgr *process.Manager) CommandAutocomplete { c := NewCommandAutocomplete(scripts) c.processMgr = processMgr return c } func (c CommandAutocomplete) Init() tea.Cmd { return textinput.Blink } func (c CommandAutocomplete) Update(msg tea.Msg) (CommandAutocomplete, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "tab": if c.showDropdown && len(c.suggestions) > 0 { c.applySelectedSuggestion() c.updateSuggestions() } return c, nil case "up": if c.showDropdown && c.selected > 0 { c.selected-- } return c, nil case "down": if c.showDropdown && c.selected < len(c.suggestions)-1 { c.selected++ } return c, nil default: // Update input prevValue := c.input.Value() c.input, cmd = c.input.Update(msg) // Update suggestions if value changed if c.input.Value() != prevValue { c.updateSuggestions() // Clear error when user is typing c.errorMessage = "" } return c, cmd } } return c, nil } func (c *CommandAutocomplete) SetWidth(width int) { c.width = width c.input.Width = width - 4 // Account for borders/padding } // SetAIProviders sets the available AI providers for autocomplete func (c *CommandAutocomplete) SetAIProviders(providers []string) { c.aiProviders = providers } func (c *CommandAutocomplete) Value() string { return c.input.Value() } func (c *CommandAutocomplete) SetValue(value string) { c.input.SetValue(value) c.updateSuggestions() } func (c *CommandAutocomplete) Focus() { c.input.Focus() } func (c *CommandAutocomplete) Blur() { c.input.Blur() } func (c *CommandAutocomplete) updateSuggestions() { value := c.input.Value() // Add the slash prefix since it's in the prompt if !strings.HasPrefix(value, "/") && value != "" { value = "/" + value } c.segments = strings.Fields(value) // Determine which segment we're currently editing if strings.HasSuffix(value, " ") { // We're starting a new segment c.currentIndex = len(c.segments) c.segments = append(c.segments, "") } else if len(c.segments) > 0 { c.currentIndex = len(c.segments) - 1 } else { c.currentIndex = 0 c.segments = []string{""} } // Get suggestions based on the command path c.suggestions = c.getSuggestionsForCurrentPosition() c.selected = 0 c.showDropdown = len(c.suggestions) > 0 // Always show dropdown if we have suggestions or if we're at the beginning if len(c.suggestions) == 0 && c.currentIndex == 0 && (value == "" || value == "/") { // Show initial commands when empty c.suggestions = []string{"run", "restart", "stop", "clear", "show", "hide", "proxy", "toggle-proxy", "ai", "term", "help"} c.showDropdown = true } // Ensure selected index is valid if c.selected >= len(c.suggestions) { c.selected = 0 } } func (c *CommandAutocomplete) getSuggestionsForCurrentPosition() []string { if c.currentIndex == 0 { // First segment - show root commands rootCommands := []string{"run", "restart", "stop", "clear", "show", "hide", "proxy", "toggle-proxy", "ai", "term", "help"} currentText := "" if len(c.segments) > 0 { currentText = c.segments[0] // Remove leading slash for comparison currentText = strings.TrimPrefix(currentText, "/") } return c.filterSuggestions(rootCommands, currentText) } // For subsequent segments, look up based on the first command if c.currentIndex == 1 && len(c.segments) > 0 { switch c.segments[0] { case "/run": // Get script names, excluding already running ones scripts := make([]string, 0, len(c.availableScripts)) // Get running scripts if process manager is available runningScripts := make(map[string]bool) if c.processMgr != nil { for _, proc := range c.processMgr.GetAllProcesses() { if proc.GetStatus() == process.StatusRunning { runningScripts[proc.Name] = true } } } // Only add scripts that aren't already running for name := range c.availableScripts { if !runningScripts[name] { scripts = append(scripts, name) } } sort.Strings(scripts) currentText := "" if c.currentIndex < len(c.segments) { currentText = c.segments[c.currentIndex] } return c.filterSuggestions(scripts, currentText) case "/restart", "/stop": // Get running processes with "all" as default option processes := []string{"all"} if c.processMgr != nil { for _, proc := range c.processMgr.GetAllProcesses() { if proc.GetStatus() == process.StatusRunning { processes = append(processes, proc.Name) } } } currentText := "" if c.currentIndex < len(c.segments) { currentText = c.segments[c.currentIndex] } return c.filterSuggestions(processes, currentText) case "/clear": // Options: all, logs, errors, web, or script names options := []string{"all", "logs", "errors", "web"} // Add all script names (both running and not running) if c.availableScripts != nil { for scriptName := range c.availableScripts { options = append(options, scriptName) } } currentText := "" if c.currentIndex < len(c.segments) { currentText = c.segments[c.currentIndex] } return c.filterSuggestions(options, currentText) case "/show", "/hide": // Common patterns for log filtering patterns := []string{"error", "warn", "info", "debug", "^\\[", "\\]$", "|"} currentText := "" if c.currentIndex < len(c.segments) { currentText = c.segments[c.currentIndex] } return c.filterSuggestions(patterns, currentText) case "/proxy": // URL format suggestions urlExamples := []string{"http://localhost:3000", "http://localhost:8080", "https://example.com"} currentText := "" if c.currentIndex < len(c.segments) { currentText = c.segments[c.currentIndex] } return c.filterSuggestions(urlExamples, currentText) case "/ai": // Return available AI providers if len(c.aiProviders) > 0 { currentText := "" if c.currentIndex < len(c.segments) { currentText = c.segments[c.currentIndex] } return c.filterSuggestions(c.aiProviders, currentText) } // No providers configured - show help message return []string{"<no providers configured - add to .brum.toml>"} } } return []string{} } func (c *CommandAutocomplete) filterSuggestions(options []string, prefix string) []string { if prefix == "" { return options } var filtered []string prefixLower := strings.ToLower(prefix) for _, opt := range options { if strings.HasPrefix(strings.ToLower(opt), prefixLower) { filtered = append(filtered, opt) } } return filtered } func (c *CommandAutocomplete) applySelectedSuggestion() { if len(c.suggestions) == 0 || c.selected >= len(c.suggestions) { return } selected := c.suggestions[c.selected] // Build the new command string parts := strings.Fields(c.input.Value()) if strings.HasSuffix(c.input.Value(), " ") { // We're completing a new segment parts = append(parts, selected) } else if len(parts) > 0 { // Replace the last segment parts[len(parts)-1] = selected } else { parts = []string{selected} } // Set the new value with a trailing space (except for commands) newValue := strings.Join(parts, " ") if c.currentIndex == 0 { newValue += " " } c.input.SetValue(newValue) c.input.CursorEnd() } func (c CommandAutocomplete) View() string { // Just return the input view - the dropdown will be rendered separately return c.input.View() } func (c CommandAutocomplete) RenderDropdown(maxSuggestions int) string { if !c.showDropdown || len(c.suggestions) == 0 { return "" } var s strings.Builder selectedStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("226")). Background(lipgloss.Color("237")). Width(c.width - 4) normalStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("252")). Width(c.width - 4) count := len(c.suggestions) if count > maxSuggestions { count = maxSuggestions } for i := 0; i < count; i++ { suggestion := c.suggestions[i] // Add visual indicator for selection display := " " + suggestion if i == c.selected { display = "▶ " + suggestion s.WriteString(selectedStyle.Render(display)) } else { s.WriteString(normalStyle.Render(display)) } if i < count-1 { s.WriteString("\n") } } // Show more indicator if there are more suggestions if len(c.suggestions) > maxSuggestions { moreStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("241")). Italic(true) s.WriteString("\n") moreCount := len(c.suggestions) - maxSuggestions s.WriteString(moreStyle.Render(fmt.Sprintf(" ... and %d more", moreCount))) } return s.String() } func (c CommandAutocomplete) GetSuggestions() []string { return c.suggestions } func (c CommandAutocomplete) GetSelected() int { return c.selected } // ValidateInput checks if the current input is valid and can be executed func (c *CommandAutocomplete) ValidateInput() (bool, string) { value := c.input.Value() if value == "" { return false, "Please enter a command" } // If it doesn't start with /, treat as arbitrary command (always valid) if !strings.HasPrefix(value, "/") { return true, "" } parts := strings.Fields(value) if len(parts) == 0 { return false, "Please enter a command" } command := parts[0] switch command { case "/run": if len(parts) < 2 { return false, "Please specify a script name (e.g., /run dev)" } scriptName := parts[1] // Check if script exists if _, exists := c.availableScripts[scriptName]; !exists { return false, fmt.Sprintf("Script '%s' not found. Available: %s", scriptName, c.getAvailableScriptsString()) } // Check if script is already running if c.processMgr != nil { for _, proc := range c.processMgr.GetAllProcesses() { if proc.Name == scriptName && proc.GetStatus() == process.StatusRunning { return false, fmt.Sprintf("Script '%s' is already running", scriptName) } } } return true, "" case "/restart", "/stop": if len(parts) < 2 { // Default to "all" if no process specified return true, "" } processName := parts[1] // Check if it's "all" or a valid running process if processName == "all" { return true, "" } // Check if process exists and is running if c.processMgr != nil { for _, proc := range c.processMgr.GetAllProcesses() { if proc.Name == processName && proc.GetStatus() == process.StatusRunning { return true, "" } } return false, fmt.Sprintf("Process '%s' is not running", processName) } return true, "" case "/clear": if len(parts) < 2 { // Default to "all" if no target specified return true, "" } target := parts[1] // Check if it's a valid clear target validTargets := map[string]bool{ "all": true, "logs": true, "errors": true, "web": true, } if validTargets[target] { return true, "" } // Check if it's a valid script name if _, exists := c.availableScripts[target]; exists { return true, "" } return false, fmt.Sprintf("Invalid clear target '%s'. Use: all, logs, errors, or a script name", target) case "/show", "/hide": if len(parts) < 2 { return false, fmt.Sprintf("Please specify a pattern for %s", command) } return true, "" case "/proxy": if len(parts) < 2 { return false, "Please specify a URL (e.g. /proxy http://localhost:3000)" } urlStr := parts[1] if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") { return false, "URL must start with http:// or https://" } return true, "" case "/toggle-proxy": // No additional parameters needed return true, "" case "/ai": if len(parts) < 2 { if len(c.aiProviders) == 0 { return false, "No AI providers configured. Add [aicoder.providers.<name>] sections to .brum.toml" } return false, fmt.Sprintf("Please specify an AI provider. Available: %s", strings.Join(c.aiProviders, ", ")) } providerName := parts[1] // Check if provider exists if len(c.aiProviders) > 0 { found := false for _, provider := range c.aiProviders { if provider == providerName { found = true break } } if !found { return false, fmt.Sprintf("AI provider '%s' not found. Available: %s", providerName, strings.Join(c.aiProviders, ", ")) } } else { // No providers configured return false, "No AI providers configured. Add [aicoder.providers.<name>] sections to .brum.toml" } return true, "" case "/term": // No additional parameters needed for terminal return true, "" case "/help": // No additional parameters needed return true, "" default: // Check if it's a partial command for _, cmd := range []string{"run", "restart", "stop", "clear", "show", "hide", "proxy", "toggle-proxy", "ai", "term", "help"} { if strings.HasPrefix(cmd, strings.TrimPrefix(command, "/")) { return false, fmt.Sprintf("Incomplete command. Did you mean /%s?", cmd) } } return false, fmt.Sprintf("Unknown command: %s. Available commands: /run, /restart, /stop, /clear, /show, /hide, /proxy, /toggle-proxy, /ai, /term, /help", command) } } func (c *CommandAutocomplete) getAvailableScriptsString() string { // Get running scripts if process manager is available runningScripts := make(map[string]bool) if c.processMgr != nil { for _, proc := range c.processMgr.GetAllProcesses() { if proc.GetStatus() == process.StatusRunning { runningScripts[proc.Name] = true } } } // Only show scripts that aren't already running scripts := make([]string, 0, len(c.availableScripts)) for name := range c.availableScripts { if !runningScripts[name] { scripts = append(scripts, name) } } sort.Strings(scripts) if len(scripts) > 5 { return strings.Join(scripts[:5], ", ") + "..." } return strings.Join(scripts, ", ") } // GetErrorMessage returns the current error message func (c CommandAutocomplete) GetErrorMessage() string { return c.errorMessage } // ClearError clears the error message func (c *CommandAutocomplete) ClearError() { c.errorMessage = "" } // SetError sets an error message func (c *CommandAutocomplete) SetError(message string) { c.errorMessage = message }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/standardbeagle/brummer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server