Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
75 changes: 26 additions & 49 deletions internal/ui/cli.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, "/")
Expand All @@ -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()
Expand All @@ -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.")
Expand All @@ -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:
Expand Down
82 changes: 82 additions & 0 deletions internal/ui/commands.go
Original file line number Diff line number Diff line change
@@ -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
}
139 changes: 139 additions & 0 deletions internal/ui/fuzzy.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading