Skip to main content
Glama
script_selector_controller.go11.7 kB
package tui import ( "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/standardbeagle/brummer/internal/logs" "github.com/standardbeagle/brummer/internal/process" ) // ScriptSelectorController manages the script selection UI type ScriptSelectorController struct { // UI components scriptSelector CommandAutocomplete visible bool quickMode bool // Dependencies processMgr *process.Manager logStore *logs.Store updateChan chan tea.Msg navController NavigationControllerInterface } // NewScriptSelectorController creates a new script selector controller func NewScriptSelectorController(scripts map[string]string, processMgr *process.Manager, logStore *logs.Store, updateChan chan tea.Msg, navController NavigationControllerInterface) *ScriptSelectorController { scriptMap := scripts scriptSelector := NewScriptSelectorAutocompleteWithProcessManager(scriptMap, processMgr) // Width will be set dynamically in View() based on terminal size return &ScriptSelectorController{ scriptSelector: scriptSelector, processMgr: processMgr, logStore: logStore, updateChan: updateChan, navController: navController, } } // IsVisible returns whether the script selector is visible func (c *ScriptSelectorController) IsVisible() bool { return c.visible } // Show displays the script selector func (c *ScriptSelectorController) Show(quickMode bool) { c.visible = true c.quickMode = quickMode c.scriptSelector.Focus() if quickMode { // Quick mode - clear for typing c.scriptSelector.input.SetValue("") c.scriptSelector.selected = 0 c.scriptSelector.showDropdown = true } else { // Normal mode - ensure dropdown shows all scripts c.scriptSelector.input.SetValue("") // Start with empty to show all c.scriptSelector.updateScriptSelectorSuggestions() c.scriptSelector.showDropdown = true } } // Hide hides the script selector func (c *ScriptSelectorController) Hide() { c.visible = false c.quickMode = false c.scriptSelector.input.SetValue("") c.scriptSelector.errorMessage = "" } // EnterArbitraryMode switches to arbitrary command mode func (c *ScriptSelectorController) EnterArbitraryMode() { c.scriptSelector.input.SetValue("") c.scriptSelector.input.Placeholder = "Type any command (e.g., 'ls', 'node server.js')..." c.scriptSelector.suggestions = []string{} c.scriptSelector.showDropdown = false c.scriptSelector.errorMessage = "" c.scriptSelector.arbitraryMode = true } // HandleKeyMsg processes keyboard input for the script selector func (c *ScriptSelectorController) HandleKeyMsg(msg tea.KeyMsg) (bool, tea.Cmd) { if c == nil || !c.visible { return false, nil } switch msg.String() { case "esc": c.Hide() return true, nil case "ctrl+n": c.EnterArbitraryMode() return true, nil case "enter": return c.handleEnter() case "up": c.navigateUp() return true, nil case "down": c.navigateDown() return true, nil case "tab": c.autocomplete() return true, nil } // Update input var cmd tea.Cmd c.scriptSelector.input, cmd = c.scriptSelector.input.Update(msg) // Update suggestions if not in arbitrary mode if !c.scriptSelector.arbitraryMode { c.updateSuggestions() } return true, cmd } // handleEnter processes the enter key func (c *ScriptSelectorController) handleEnter() (bool, tea.Cmd) { if c.scriptSelector.arbitraryMode { // Execute arbitrary command command := strings.TrimSpace(c.scriptSelector.input.Value()) if command != "" { parts := strings.Fields(command) if len(parts) > 0 { cmdName := parts[0] args := parts[1:] // Start the command SafeGoroutine( fmt.Sprintf("start script selector command '%s'", cmdName), func() error { if c.processMgr == nil { return fmt.Errorf(ErrProcessManagerNotInitialized) } _, err := c.processMgr.StartCommand(command, cmdName, args) if err == nil && c.navController != nil { // Success - switch to logs view c.navController.SwitchTo(ViewLogs) if c.updateChan != nil { c.updateChan <- tea.Msg(nil) } } return err }, func(err error) { errorMsg := fmt.Sprintf(ErrFailedToStartCommand, cmdName, args, err) c.scriptSelector.errorMessage = errorMsg if c.logStore != nil { c.logStore.Add("system", "System", errorMsg, true) } if c.updateChan != nil { c.updateChan <- tea.Msg(nil) } }, ) c.Hide() } } return true, nil } // Execute selected script if c.scriptSelector.selected >= 0 && c.scriptSelector.selected < len(c.scriptSelector.suggestions) { scriptName := c.scriptSelector.suggestions[c.scriptSelector.selected] SafeGoroutine( fmt.Sprintf("start selected script '%s'", scriptName), func() error { if c.processMgr == nil { return fmt.Errorf(ErrProcessManagerNotInitialized) } _, err := c.processMgr.StartScript(scriptName) if err == nil && c.navController != nil { // Success - switch to logs view c.navController.SwitchTo(ViewLogs) if c.updateChan != nil { c.updateChan <- tea.Msg(nil) } } return err }, func(err error) { errorMsg := fmt.Sprintf(ErrFailedToStartScript, scriptName, err) c.scriptSelector.errorMessage = errorMsg if c.logStore != nil { c.logStore.Add("system", "System", errorMsg, true) } if c.updateChan != nil { c.updateChan <- tea.Msg(nil) } }, ) c.Hide() } return true, nil } // navigateUp moves selection up func (c *ScriptSelectorController) navigateUp() { if len(c.scriptSelector.suggestions) > 0 && c.scriptSelector.showDropdown { if c.scriptSelector.selected > 0 { c.scriptSelector.selected-- } } } // navigateDown moves selection down func (c *ScriptSelectorController) navigateDown() { if len(c.scriptSelector.suggestions) > 0 && c.scriptSelector.showDropdown { if c.scriptSelector.selected < len(c.scriptSelector.suggestions)-1 { c.scriptSelector.selected++ } } } // autocomplete fills in the selected suggestion func (c *ScriptSelectorController) autocomplete() { if len(c.scriptSelector.suggestions) > 0 && c.scriptSelector.selected >= 0 { selected := c.scriptSelector.suggestions[c.scriptSelector.selected] c.scriptSelector.input.SetValue(selected) c.scriptSelector.showDropdown = false } } // updateSuggestions updates the suggestion list based on input func (c *ScriptSelectorController) updateSuggestions() { inputValue := c.scriptSelector.input.Value() if inputValue == "" { // Show all scripts scripts := make([]string, 0, len(c.scriptSelector.availableScripts)) for name := range c.scriptSelector.availableScripts { scripts = append(scripts, name) } c.scriptSelector.suggestions = scripts c.scriptSelector.showDropdown = true } else { // Filter scripts var filtered []string for script := range c.scriptSelector.availableScripts { if strings.HasPrefix(strings.ToLower(script), strings.ToLower(inputValue)) { filtered = append(filtered, script) } } c.scriptSelector.suggestions = filtered c.scriptSelector.showDropdown = len(filtered) > 0 } // Reset selection if out of bounds if c.scriptSelector.selected >= len(c.scriptSelector.suggestions) { c.scriptSelector.selected = 0 } } // View returns the rendered view func (c *ScriptSelectorController) View() string { if !c.visible { return "" } // Update autocomplete width based on terminal size c.updateAutocompleteWidth() // Get terminal dimensions and max suggestions termWidth, termHeight := c.getTerminalDimensions() maxSuggestions := c.getMaxSuggestions(termHeight) // Render input and dropdown components input := c.scriptSelector.View() dropdown := c.scriptSelector.RenderDropdown(maxSuggestions) // Build the complete view using Lipgloss centering return c.buildView(termWidth, termHeight, input, dropdown) } // updateAutocompleteWidth sets the width of the autocomplete based on terminal size func (c *ScriptSelectorController) updateAutocompleteWidth() { if termWidth, _, err := getTerminalSize(); err == nil && termWidth > 0 { autocompleteWidth := termWidth - ScriptSelectorMargin if autocompleteWidth < ScriptSelectorMinWidth { autocompleteWidth = ScriptSelectorMinWidth } if autocompleteWidth > ScriptSelectorMaxWidth { autocompleteWidth = ScriptSelectorMaxWidth } c.scriptSelector.SetWidth(autocompleteWidth) } } // getTerminalDimensions returns the terminal width and height with fallback values func (c *ScriptSelectorController) getTerminalDimensions() (int, int) { termWidth, termHeight, _ := getTerminalSize() if termWidth == 0 || termHeight == 0 { // Fallback dimensions termWidth = 80 termHeight = 24 } return termWidth, termHeight } // getMaxSuggestions returns the maximum number of suggestions based on terminal height func (c *ScriptSelectorController) getMaxSuggestions(termHeight int) int { if termHeight > 0 && termHeight < SmallTerminalHeightThreshold { return MaxDropdownSuggestionsSmall } return MaxDropdownSuggestions } // buildView constructs the complete view with all components using Lipgloss layouts func (c *ScriptSelectorController) buildView(termWidth, termHeight int, input, dropdown string) string { // Create base container style containerStyle := lipgloss.NewStyle(). Width(termWidth). Height(termHeight). Align(lipgloss.Center, lipgloss.Center) // Create content sections title := c.renderTitle(termWidth) content := c.renderContent(termWidth, input, dropdown) helpText := c.renderHelpText(termWidth) // Join sections vertically with proper spacing fullContent := lipgloss.JoinVertical( lipgloss.Center, title, content, helpText, ) // Apply container style for centering return containerStyle.Render(fullContent) } // renderTitle creates the centered title using Lipgloss func (c *ScriptSelectorController) renderTitle(termWidth int) string { titleStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("226")). Width(termWidth). Align(lipgloss.Center). MarginBottom(1) return titleStyle.Render("🐝 Select a Script to Run") } // renderContent creates the input and dropdown section using Lipgloss func (c *ScriptSelectorController) renderContent(termWidth int, input, dropdown string) string { // Create content container with proper width contentWidth := c.scriptSelector.width if contentWidth > termWidth-10 { contentWidth = termWidth - 10 } contentStyle := lipgloss.NewStyle(). Width(contentWidth). Align(lipgloss.Left) // Container for centering content horizontally containerStyle := lipgloss.NewStyle(). Width(termWidth). Align(lipgloss.Center) // Build content sections var sections []string // Add input sections = append(sections, contentStyle.Render(input)) // Add dropdown if present if dropdown != "" { sections = append(sections, contentStyle.Render(dropdown)) } // Join sections and center content := lipgloss.JoinVertical(lipgloss.Left, sections...) return containerStyle.Render(content) } // renderHelpText creates the centered help text using Lipgloss func (c *ScriptSelectorController) renderHelpText(termWidth int) string { helpStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("245")). Width(termWidth). Align(lipgloss.Center). MarginTop(1) return helpStyle.Render("↑/↓ Navigate • Enter Run • Tab Complete • Esc Cancel") } // GetScriptSelector returns the underlying CommandAutocomplete for backward compatibility func (c *ScriptSelectorController) GetScriptSelector() *CommandAutocomplete { return &c.scriptSelector }

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