diff --git a/cmd/root.go b/cmd/root.go index 9fba7be..bc55131 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -931,7 +931,7 @@ func runInteractiveLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, // Get user input prompt, err := cli.GetPrompt() if err == io.EOF { - fmt.Println("\nGoodbye!") + fmt.Println("\n Goodbye!") return nil } if err != nil { @@ -944,7 +944,7 @@ func runInteractiveLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, // Handle slash commands if cli.IsSlashCommand(prompt) { - result := cli.HandleSlashCommand(prompt, config.ServerNames, config.ToolNames, messages) + result := cli.HandleSlashCommand(prompt, config.ServerNames, config.ToolNames) if result.Handled { // If the command was to clear history, clear the messages slice and session if result.ClearHistory { diff --git a/internal/ui/cli.go b/internal/ui/cli.go index 82de34b..26efe73 100644 --- a/internal/ui/cli.go +++ b/internal/ui/cli.go @@ -1,14 +1,13 @@ package ui import ( - "errors" "fmt" "io" "os" "strings" "time" - "github.com/charmbracelet/huh" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/cloudwego/eino/schema" "golang.org/x/term" @@ -71,23 +70,34 @@ func (c *CLI) GetPrompt() (string, error) { // No divider needed - removed for cleaner appearance - var prompt string - err := huh.NewForm(huh.NewGroup(huh.NewText(). - Title("Enter your prompt (Type /help for commands, Ctrl+C to quit, ESC to cancel generation)"). - Value(&prompt). - CharLimit(5000)), - ).WithWidth(c.width). - WithTheme(huh.ThemeCharm()). - Run() + // Create our custom slash command input + input := NewSlashCommandInput(c.width, "Enter your prompt (Type /help for commands, Ctrl+C to quit, ESC to cancel generation)") + + // Run as a tea program + p := tea.NewProgram(input) + finalModel, err := p.Run() if err != nil { - if errors.Is(err, huh.ErrUserAborted) { + return "", err + } + + // Get the value from the final model + if finalInput, ok := finalModel.(*SlashCommandInput); ok { + // Clear the input field from the display + linesToClear := finalInput.RenderedLines() + // We need to clear linesToClear - 1 lines because we're already on the line after the last rendered line + for i := 0; i < linesToClear-1; i++ { + fmt.Print("\033[1A\033[2K") // Move up one line and clear it + } + + if finalInput.Cancelled() { return "", io.EOF // Signal clean exit } - return "", err + value := strings.TrimSpace(finalInput.Value()) + return value, nil } - return prompt, nil + return "", fmt.Errorf("unexpected model type") } // ShowSpinner displays a spinner with the given message and executes the action @@ -241,7 +251,6 @@ func (c *CLI) DisplayHelp() { - ` + "`/help`" + `: Show this help message - ` + "`/tools`" + `: List all available tools - ` + "`/servers`" + `: List configured MCP servers -- ` + "`/history`" + `: Display conversation history - ` + "`/usage`" + `: Show token usage and cost statistics - ` + "`/reset-usage`" + `: Reset usage statistics - ` + "`/clear`" + `: Clear message history @@ -295,36 +304,6 @@ func (c *CLI) DisplayServers(servers []string) { c.displayContainer() } -// DisplayHistory displays conversation history using the message container -func (c *CLI) DisplayHistory(messages []*schema.Message) { - // Create a temporary container for history - historyContainer := NewMessageContainer(c.width, c.height-4, c.compactMode) - - for _, msg := range messages { - switch msg.Role { - case schema.User: - var uiMsg UIMessage - if c.compactMode { - uiMsg = c.compactRenderer.RenderUserMessage(msg.Content, time.Now()) - } else { - uiMsg = c.messageRenderer.RenderUserMessage(msg.Content, time.Now()) - } - historyContainer.AddMessage(uiMsg) - case schema.Assistant: - var uiMsg UIMessage - if c.compactMode { - uiMsg = c.compactRenderer.RenderAssistantMessage(msg.Content, time.Now(), c.modelName) - } else { - uiMsg = c.messageRenderer.RenderAssistantMessage(msg.Content, time.Now(), c.modelName) - } - historyContainer.AddMessage(uiMsg) - } - } - - fmt.Println("\nConversation History:") - fmt.Println(historyContainer.Render()) -} - // IsSlashCommand checks if the input is a slash command func (c *CLI) IsSlashCommand(input string) bool { return strings.HasPrefix(input, "/") @@ -337,7 +316,7 @@ type SlashCommandResult struct { } // HandleSlashCommand handles slash commands and returns the result -func (c *CLI) HandleSlashCommand(input string, servers []string, tools []string, history []*schema.Message) SlashCommandResult { +func (c *CLI) HandleSlashCommand(input string, servers []string, tools []string) SlashCommandResult { switch input { case "/help": c.DisplayHelp() @@ -348,9 +327,7 @@ func (c *CLI) HandleSlashCommand(input string, servers []string, tools []string, case "/servers": c.DisplayServers(servers) return SlashCommandResult{Handled: true} - case "/history": - c.DisplayHistory(history) - return SlashCommandResult{Handled: true} + case "/clear": c.ClearMessages() c.DisplayInfo("Conversation cleared. Starting fresh.") @@ -362,7 +339,7 @@ func (c *CLI) HandleSlashCommand(input string, servers []string, tools []string, c.ResetUsageStats() return SlashCommandResult{Handled: true} case "/quit": - fmt.Println("\nGoodbye!") + fmt.Println("\n Goodbye!") os.Exit(0) return SlashCommandResult{Handled: true} default: diff --git a/internal/ui/commands.go b/internal/ui/commands.go new file mode 100644 index 0000000..02200cc --- /dev/null +++ b/internal/ui/commands.go @@ -0,0 +1,82 @@ +package ui + +// SlashCommand represents a slash command with its metadata +type SlashCommand struct { + Name string + Description string + Aliases []string + Category string // e.g., "Navigation", "System", "Info" +} + +// SlashCommands is the registry of all available slash commands +var SlashCommands = []SlashCommand{ + { + Name: "/help", + Description: "Show available commands and usage information", + Category: "Info", + Aliases: []string{"/h", "/?"}, + }, + { + Name: "/tools", + Description: "List all available MCP tools", + Category: "Info", + Aliases: []string{"/t"}, + }, + { + Name: "/servers", + Description: "Show connected MCP servers", + Category: "Info", + Aliases: []string{"/s"}, + }, + + { + Name: "/clear", + Description: "Clear conversation and start fresh", + Category: "System", + Aliases: []string{"/c", "/cls"}, + }, + { + Name: "/usage", + Description: "Show token usage statistics", + Category: "Info", + Aliases: []string{"/u"}, + }, + { + Name: "/reset-usage", + Description: "Reset usage statistics", + Category: "System", + Aliases: []string{"/ru"}, + }, + { + Name: "/quit", + Description: "Exit the application", + Category: "System", + Aliases: []string{"/q", "/exit"}, + }, +} + +// GetCommandByName returns a command by its name or alias +func GetCommandByName(name string) *SlashCommand { + for i := range SlashCommands { + cmd := &SlashCommands[i] + if cmd.Name == name { + return cmd + } + for _, alias := range cmd.Aliases { + if alias == name { + return cmd + } + } + } + return nil +} + +// GetAllCommandNames returns all command names and aliases +func GetAllCommandNames() []string { + var names []string + for _, cmd := range SlashCommands { + names = append(names, cmd.Name) + names = append(names, cmd.Aliases...) + } + return names +} diff --git a/internal/ui/fuzzy.go b/internal/ui/fuzzy.go new file mode 100644 index 0000000..1c7bddd --- /dev/null +++ b/internal/ui/fuzzy.go @@ -0,0 +1,139 @@ +package ui + +import ( + "strings" +) + +// FuzzyMatch represents a match result with score +type FuzzyMatch struct { + Command *SlashCommand + Score int +} + +// FuzzyMatchCommands performs fuzzy matching on slash commands +func FuzzyMatchCommands(query string, commands []SlashCommand) []FuzzyMatch { + if query == "" || query == "/" { + // Return all commands when query is empty or just "/" + matches := make([]FuzzyMatch, len(commands)) + for i := range commands { + matches[i] = FuzzyMatch{ + Command: &commands[i], + Score: 0, + } + } + return matches + } + + // Normalize query + query = strings.ToLower(strings.TrimPrefix(query, "/")) + + var matches []FuzzyMatch + + for i := range commands { + cmd := &commands[i] + score := fuzzyScore(query, cmd) + if score > 0 { + matches = append(matches, FuzzyMatch{ + Command: cmd, + Score: score, + }) + } + } + + // Sort by score (highest first) + for i := 0; i < len(matches)-1; i++ { + for j := i + 1; j < len(matches); j++ { + if matches[j].Score > matches[i].Score { + matches[i], matches[j] = matches[j], matches[i] + } + } + } + + return matches +} + +// fuzzyScore calculates the fuzzy match score for a command +func fuzzyScore(query string, cmd *SlashCommand) int { + // Check exact match first + cmdName := strings.ToLower(strings.TrimPrefix(cmd.Name, "/")) + if cmdName == query { + return 1000 + } + + // Check aliases for exact match + for _, alias := range cmd.Aliases { + aliasName := strings.ToLower(strings.TrimPrefix(alias, "/")) + if aliasName == query { + return 900 + } + } + + // Check if command starts with query + if strings.HasPrefix(cmdName, query) { + return 800 - len(cmdName) + len(query) + } + + // Check if any alias starts with query + for _, alias := range cmd.Aliases { + aliasName := strings.ToLower(strings.TrimPrefix(alias, "/")) + if strings.HasPrefix(aliasName, query) { + return 700 - len(aliasName) + len(query) + } + } + + // Check if command contains query + if strings.Contains(cmdName, query) { + return 500 + } + + // Check if description contains query + if strings.Contains(strings.ToLower(cmd.Description), query) { + return 300 + } + + // Fuzzy character matching + score := fuzzyCharacterMatch(query, cmdName) + if score > 0 { + return score + } + + // Try fuzzy matching on aliases + for _, alias := range cmd.Aliases { + aliasName := strings.ToLower(strings.TrimPrefix(alias, "/")) + score = fuzzyCharacterMatch(query, aliasName) + if score > 0 { + return score - 50 // Slightly lower score for alias matches + } + } + + return 0 +} + +// fuzzyCharacterMatch performs character-by-character fuzzy matching +func fuzzyCharacterMatch(query, target string) int { + if len(query) > len(target) { + return 0 + } + + queryIdx := 0 + score := 100 + consecutiveMatches := 0 + + for i := 0; i < len(target) && queryIdx < len(query); i++ { + if target[i] == query[queryIdx] { + queryIdx++ + consecutiveMatches++ + score += consecutiveMatches * 10 + } else { + consecutiveMatches = 0 + score -= 5 + } + } + + // Must match all characters in query + if queryIdx < len(query) { + return 0 + } + + return score +} diff --git a/internal/ui/messages.go b/internal/ui/messages.go index b89571c..7685cc9 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -544,6 +544,7 @@ type MessageContainer struct { height int compactMode bool // Add compact mode flag modelName string // Store current model name + wasCleared bool // Track if container was explicitly cleared } // NewMessageContainer creates a new message container @@ -559,6 +560,7 @@ func NewMessageContainer(width, height int, compact bool) *MessageContainer { // AddMessage adds a message to the container func (c *MessageContainer) AddMessage(msg UIMessage) { c.messages = append(c.messages, msg) + c.wasCleared = false // Reset the cleared flag when adding messages } // SetModelName sets the current model name for the container @@ -594,6 +596,7 @@ func (c *MessageContainer) UpdateLastMessage(content string) { // Clear clears all messages from the container func (c *MessageContainer) Clear() { c.messages = make([]UIMessage, 0) + c.wasCleared = true } // SetSize updates the container size @@ -605,6 +608,10 @@ func (c *MessageContainer) SetSize(width, height int) { // Render renders all messages in the container func (c *MessageContainer) Render() string { if len(c.messages) == 0 { + // Don't show welcome box if explicitly cleared + if c.wasCleared { + return "" + } if c.compactMode { return c.renderCompactEmptyState() } diff --git a/internal/ui/slash_command_input.go b/internal/ui/slash_command_input.go new file mode 100644 index 0000000..a2cac6e --- /dev/null +++ b/internal/ui/slash_command_input.go @@ -0,0 +1,340 @@ +package ui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// SlashCommandInput is a custom input field with slash command autocomplete +type SlashCommandInput struct { + textarea textarea.Model + commands []SlashCommand + showPopup bool + filtered []FuzzyMatch + selected int + width int + lastValue string + popupHeight int + title string + quitting bool + value string + submitNext bool // Flag to submit on next update + renderedLines int // Track how many lines were rendered +} + +// NewSlashCommandInput creates a new slash command input field +func NewSlashCommandInput(width int, title string) *SlashCommandInput { + ta := textarea.New() + ta.Placeholder = "Type your message..." + ta.ShowLineNumbers = false + ta.Prompt = "" + ta.CharLimit = 5000 + ta.SetWidth(width - 8) // Account for container padding, border and internal padding + ta.SetHeight(3) // Default to 3 lines like huh + ta.Focus() + + // Style the textarea to match huh theme + ta.FocusedStyle.Base = lipgloss.NewStyle() + ta.FocusedStyle.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + ta.FocusedStyle.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + ta.FocusedStyle.Prompt = lipgloss.NewStyle() + ta.FocusedStyle.CursorLine = lipgloss.NewStyle() + ta.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) + + return &SlashCommandInput{ + textarea: ta, + commands: SlashCommands, + width: width, + popupHeight: 7, + title: title, + } +} + +// Init implements tea.Model +func (s *SlashCommandInput) Init() tea.Cmd { + return textarea.Blink +} + +// Update implements tea.Model +func (s *SlashCommandInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + // Check if we need to submit after updating the view + if s.submitNext { + s.value = s.textarea.Value() + s.quitting = true + return s, tea.Quit + } + + switch msg := msg.(type) { + case tea.KeyMsg: // Check for quit keys first (when popup is not shown) + if !s.showPopup { + switch msg.String() { + case "ctrl+c", "esc": + s.quitting = true + return s, tea.Quit + case "ctrl+d": // Submit on Ctrl+D like huh + s.value = s.textarea.Value() + s.quitting = true + return s, tea.Quit + } + + // Check for newline keys first + if msg.String() == "ctrl+j" || msg.String() == "alt+enter" { + // Insert newline at cursor position + s.textarea, cmd = s.textarea.Update(tea.KeyMsg{Type: tea.KeyEnter, Alt: true}) + return s, cmd + } else if msg.String() == "enter" && !strings.Contains(s.textarea.Value(), "\n") { + // Submit on Enter only if it's single line + s.value = s.textarea.Value() + s.quitting = true + return s, tea.Quit + } + } + + // Handle popup navigation + if s.showPopup { + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up"))): + if s.selected > 0 { + s.selected-- + } + return s, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down"))): + if s.selected < len(s.filtered)-1 { + s.selected++ + } + return s, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))): + if s.selected < len(s.filtered) { + // Complete with selected command + s.textarea.SetValue(s.filtered[s.selected].Command.Name) + s.showPopup = false + s.selected = 0 + // Move cursor to end + s.textarea.CursorEnd() + } + return s, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + if s.selected < len(s.filtered) { + // Populate the field with the selected command + s.textarea.SetValue(s.filtered[s.selected].Command.Name) + s.textarea.CursorEnd() + // Hide the popup + s.showPopup = false + s.selected = 0 + // Set flag to submit on next update (after view refresh) + s.submitNext = true + // Force a refresh + return s, nil + } + return s, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): + s.showPopup = false + s.selected = 0 + return s, nil + } + } + + // Update textarea + s.textarea, cmd = s.textarea.Update(msg) + + // Check if we should show/update popup + value := s.textarea.Value() + if value != s.lastValue { + s.lastValue = value + // Only show popup if we're on the first line and it starts with / + lines := strings.Split(value, "\n") + if len(lines) > 0 && strings.HasPrefix(lines[0], "/") && !strings.Contains(lines[0], " ") && len(lines) == 1 { + // Show and update popup + s.showPopup = true + s.filtered = FuzzyMatchCommands(lines[0], s.commands) + s.selected = 0 + } else { + // Hide popup + s.showPopup = false + } + } + return s, cmd + + default: + // Pass through other messages + s.textarea, cmd = s.textarea.Update(msg) + return s, cmd + } +} + +// View implements tea.Model +func (s *SlashCommandInput) View() string { + // Add left padding to entire component (2 spaces like other UI elements) + containerStyle := lipgloss.NewStyle().PaddingLeft(2) + + // Title + titleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")). + MarginBottom(1) + + // Input box with huh-like styling + inputBoxStyle := lipgloss.NewStyle(). + Border(lipgloss.ThickBorder()). + BorderLeft(true). + BorderRight(false). + BorderTop(false). + BorderBottom(false). + BorderForeground(lipgloss.Color("39")). + PaddingLeft(1). + Width(s.width - 2) // Account for container padding + + // Build the view + var view strings.Builder + view.WriteString(titleStyle.Render(s.title)) + view.WriteString("\n") + view.WriteString(inputBoxStyle.Render(s.textarea.View())) + // Count rendered lines + s.renderedLines = 2 + s.textarea.Height() // title + newline + textarea height + + // Add popup if visible + if s.showPopup && len(s.filtered) > 0 { + view.WriteString("\n") + view.WriteString(s.renderPopup()) + // Add popup lines + visibleItems := min(len(s.filtered), s.popupHeight) + scrollIndicators := 0 + if s.selected >= s.popupHeight { + scrollIndicators++ // top indicator + } + if len(s.filtered) > s.popupHeight { + scrollIndicators++ // bottom indicator + } + popupLines := visibleItems + scrollIndicators + 5 // items + scroll + border + padding + footer + s.renderedLines += 1 + popupLines // newline + popup + } + + // Add help text at bottom + helpStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + MarginTop(1) + + // Show different help based on whether we have multiline content + helpText := "enter submit" + if strings.Contains(s.textarea.Value(), "\n") { + helpText = "ctrl+d submit • enter new line" + } else { + helpText = "enter submit • ctrl+j / alt+enter new line" + } + + view.WriteString("\n") + view.WriteString(helpStyle.Render(helpText)) + s.renderedLines += 2 // newline + help text + + // Apply container padding to entire view + return containerStyle.Render(view.String()) +} + +// renderPopup renders the autocomplete popup +func (s *SlashCommandInput) renderPopup() string { + // Popup styling + popupStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("236")). + Padding(1, 2). + Width(s.width - 4). // Account for container padding + MarginLeft(0) // No extra margin needed due to container padding + + var items []string + + // Calculate visible window + visibleItems := min(len(s.filtered), s.popupHeight) + startIdx := 0 + + // Adjust window to keep selected item visible + if s.selected >= s.popupHeight { + startIdx = s.selected - s.popupHeight + 1 + } + + endIdx := min(startIdx+visibleItems, len(s.filtered)) + + for i := startIdx; i < endIdx; i++ { + match := s.filtered[i] + cmd := match.Command + // Create the selection indicator + var indicator string + if i == s.selected { + indicator = lipgloss.NewStyle(). + Foreground(lipgloss.Color("39")). + Render("> ") + } else { + indicator = " " + } + + // Format item + nameStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("39")). + Bold(true) + + descStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("243")) + + // Highlight selected item + if i == s.selected { + nameStyle = nameStyle.Foreground(lipgloss.Color("87")) + descStyle = descStyle.Foreground(lipgloss.Color("250")) + } + + // Format with proper spacing + nameWidth := 15 + name := nameStyle.Width(nameWidth - 2).Render(cmd.Name) + + // Truncate description if needed + desc := cmd.Description + maxDescLen := s.width - nameWidth - 14 // Account for padding and indicator + if len(desc) > maxDescLen && maxDescLen > 3 { + desc = desc[:maxDescLen-3] + "..." + } + + line := indicator + name + descStyle.Render(desc) + items = append(items, line) + } + + // Add scroll indicators if needed + if startIdx > 0 { + scrollUpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("238")) + items = append([]string{scrollUpStyle.Render(" ↑ more above")}, items...) + } + if endIdx < len(s.filtered) { + scrollDownStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("238")) + items = append(items, scrollDownStyle.Render(" ↓ more below")) + } + // Join items + content := strings.Join(items, "\n") + + // Add footer hint + footerStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("238")). + Italic(true) + footer := footerStyle.Render("↑↓ navigate • tab complete • ↵ select • esc dismiss") + + // Combine content and footer + popupContent := content + "\n\n" + footer + + return popupStyle.Render(popupContent) +} + +// Value returns the final value +func (s *SlashCommandInput) Value() string { + return s.value +} + +// Cancelled returns true if the user cancelled +func (s *SlashCommandInput) Cancelled() bool { + return s.quitting && s.value == "" +} + +// RenderedLines returns how many lines were rendered +func (s *SlashCommandInput) RenderedLines() int { + return s.renderedLines +}