diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go index 03110eeaf2b8fbb909f1f9e4fbd57344699732e3..86d19c3ab0450072d6a3c1fc5461f652b0ffda42 100644 --- a/internal/tui/components/dialogs/commands/arguments.go +++ b/internal/tui/components/dialogs/commands/arguments.go @@ -1,8 +1,7 @@ package commands import ( - "fmt" - "strings" + "cmp" "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/key" @@ -20,9 +19,10 @@ const ( // ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog. type ShowArgumentsDialogMsg struct { - CommandID string - Content string - ArgNames []string + CommandID string + Description string + Content string + ArgNames []string } // CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed. @@ -39,26 +39,39 @@ type CommandArgumentsDialog interface { } type commandArgumentsDialogCmp struct { - width int - wWidth int // Width of the terminal window - wHeight int // Height of the terminal window - - inputs []textinput.Model - focusIndex int - keys ArgumentsDialogKeyMap - commandID string - content string - argNames []string - help help.Model + wWidth, wHeight int + width, height int + + inputs []textinput.Model + focused int + keys ArgumentsDialogKeyMap + arguments []Argument + help help.Model + + id string + title string + name string + description string + + onSubmit func(args map[string]string) tea.Cmd +} + +type Argument struct { + Name, Title, Description string + Required bool } -func NewCommandArgumentsDialog(commandID, content string, argNames []string) CommandArgumentsDialog { +func NewCommandArgumentsDialog( + id, title, name, description string, + arguments []Argument, + onSubmit func(args map[string]string) tea.Cmd, +) CommandArgumentsDialog { t := styles.CurrentTheme() - inputs := make([]textinput.Model, len(argNames)) + inputs := make([]textinput.Model, len(arguments)) - for i, name := range argNames { + for i, arg := range arguments { ti := textinput.New() - ti.Placeholder = fmt.Sprintf("Enter value for %s...", name) + ti.Placeholder = cmp.Or(arg.Description, "Enter value for "+arg.Name) ti.SetWidth(40) ti.SetVirtualCursor(false) ti.Prompt = "" @@ -75,14 +88,16 @@ func NewCommandArgumentsDialog(commandID, content string, argNames []string) Com } return &commandArgumentsDialogCmp{ - inputs: inputs, - keys: DefaultArgumentsDialogKeyMap(), - commandID: commandID, - content: content, - argNames: argNames, - focusIndex: 0, - width: 60, - help: help.New(), + inputs: inputs, + keys: DefaultArgumentsDialogKeyMap(), + id: id, + name: name, + title: title, + description: description, + arguments: arguments, + width: 60, + help: help.New(), + onSubmit: onSubmit, } } @@ -97,41 +112,45 @@ func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: c.wWidth = msg.Width c.wHeight = msg.Height + c.width = min(90, c.wWidth) + c.height = min(15, c.wHeight) + for i := range c.inputs { + c.inputs[i].SetWidth(c.width - (paddingHorizontal * 2)) + } case tea.KeyPressMsg: switch { + case key.Matches(msg, c.keys.Cancel): + return c, util.CmdHandler(dialogs.CloseDialogMsg{}) case key.Matches(msg, c.keys.Confirm): - if c.focusIndex == len(c.inputs)-1 { - content := c.content - for i, name := range c.argNames { + if c.focused == len(c.inputs)-1 { + args := make(map[string]string) + for i, arg := range c.arguments { value := c.inputs[i].Value() - placeholder := "$" + name - content = strings.ReplaceAll(content, placeholder, value) + args[arg.Name] = value } return c, tea.Sequence( util.CmdHandler(dialogs.CloseDialogMsg{}), - util.CmdHandler(CommandRunCustomMsg{ - Content: content, - }), + c.onSubmit(args), ) } // Otherwise, move to the next input - c.inputs[c.focusIndex].Blur() - c.focusIndex++ - c.inputs[c.focusIndex].Focus() + c.inputs[c.focused].Blur() + c.focused++ + c.inputs[c.focused].Focus() case key.Matches(msg, c.keys.Next): // Move to the next input - c.inputs[c.focusIndex].Blur() - c.focusIndex = (c.focusIndex + 1) % len(c.inputs) - c.inputs[c.focusIndex].Focus() + c.inputs[c.focused].Blur() + c.focused = (c.focused + 1) % len(c.inputs) + c.inputs[c.focused].Focus() case key.Matches(msg, c.keys.Previous): // Move to the previous input - c.inputs[c.focusIndex].Blur() - c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs) - c.inputs[c.focusIndex].Focus() + c.inputs[c.focused].Blur() + c.focused = (c.focused - 1 + len(c.inputs)) % len(c.inputs) + c.inputs[c.focused].Focus() default: var cmd tea.Cmd - c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg) + c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg) return c, cmd } } @@ -147,26 +166,27 @@ func (c *commandArgumentsDialogCmp) View() string { Foreground(t.Primary). Bold(true). Padding(0, 1). - Render("Command Arguments") + Render(cmp.Or(c.title, c.name)) - explanation := t.S().Text. + promptName := t.S().Text. Padding(0, 1). - Render("This command requires arguments.") + Render(c.description) - // Create input fields for each argument inputFields := make([]string, len(c.inputs)) for i, input := range c.inputs { - // Highlight the label of the focused input - labelStyle := baseStyle. - Padding(1, 1, 0, 1) + labelStyle := baseStyle.Padding(1, 1, 0, 1) - if i == c.focusIndex { + if i == c.focused { labelStyle = labelStyle.Foreground(t.FgBase).Bold(true) } else { labelStyle = labelStyle.Foreground(t.FgMuted) } - label := labelStyle.Render(c.argNames[i] + ":") + argName := c.arguments[i].Name + if c.arguments[i].Required { + argName += " *" + } + label := labelStyle.Render(argName + ":") field := t.S().Text. Padding(0, 1). @@ -175,18 +195,14 @@ func (c *commandArgumentsDialogCmp) View() string { inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field) } - // Join all elements vertically - elements := []string{title, explanation} + elements := []string{title, promptName} elements = append(elements, inputFields...) c.help.ShowAll = false helpText := baseStyle.Padding(0, 1).Render(c.help.View(c.keys)) elements = append(elements, "", helpText) - content := lipgloss.JoinVertical( - lipgloss.Left, - elements..., - ) + content := lipgloss.JoinVertical(lipgloss.Left, elements...) return baseStyle.Padding(1, 1, 0, 1). Border(lipgloss.RoundedBorder()). @@ -196,26 +212,33 @@ func (c *commandArgumentsDialogCmp) View() string { } func (c *commandArgumentsDialogCmp) Cursor() *tea.Cursor { - cursor := c.inputs[c.focusIndex].Cursor() + if len(c.inputs) == 0 { + return nil + } + cursor := c.inputs[c.focused].Cursor() if cursor != nil { cursor = c.moveCursor(cursor) } return cursor } +const ( + headerHeight = 3 + itemHeight = 3 + paddingHorizontal = 3 +) + func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { row, col := c.Position() - offset := row + 3 + (1+c.focusIndex)*3 + offset := row + headerHeight + (1+c.focused)*itemHeight cursor.Y += offset - cursor.X = cursor.X + col + 3 + cursor.X = cursor.X + col + paddingHorizontal return cursor } func (c *commandArgumentsDialogCmp) Position() (int, int) { - row := c.wHeight / 2 - row -= c.wHeight / 2 - col := c.wWidth / 2 - col -= c.width / 2 + row := (c.wHeight / 2) - (c.height / 2) + col := (c.wWidth / 2) - (c.width / 2) return row, col } diff --git a/internal/tui/components/dialogs/commands/loader.go b/internal/tui/components/dialogs/commands/loader.go index df5caa5e2350ec1ab9c7177fb7da83f7661acb66..92ce517ce85e5f4f68e21c0c8c815d9ff5a74a48 100644 --- a/internal/tui/components/dialogs/commands/loader.go +++ b/internal/tui/components/dialogs/commands/loader.go @@ -1,6 +1,7 @@ package commands import ( + "cmp" "context" "fmt" "io/fs" @@ -20,9 +21,8 @@ import ( ) const ( - UserCommandPrefix = "user:" - ProjectCommandPrefix = "project:" - MCPPromptPrefix = "mcp:" + userCommandPrefix = "user:" + projectCommandPrefix = "project:" ) var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`) @@ -56,7 +56,7 @@ func buildCommandSources(cfg *config.Config) []commandSource { if dir := getXDGCommandsDir(); dir != "" { sources = append(sources, commandSource{ path: dir, - prefix: UserCommandPrefix, + prefix: userCommandPrefix, }) } @@ -64,14 +64,14 @@ func buildCommandSources(cfg *config.Config) []commandSource { if home := home.Dir(); home != "" { sources = append(sources, commandSource{ path: filepath.Join(home, ".crush", "commands"), - prefix: UserCommandPrefix, + prefix: userCommandPrefix, }) } // Project directory sources = append(sources, commandSource{ path: filepath.Join(cfg.Options.DataDirectory, "commands"), - prefix: ProjectCommandPrefix, + prefix: projectCommandPrefix, }) return sources @@ -133,12 +133,13 @@ func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, erro } id := buildCommandID(path, baseDir, prefix) + desc := fmt.Sprintf("Custom command from %s", filepath.Base(path)) return Command{ ID: id, Title: id, - Description: fmt.Sprintf("Custom command from %s", filepath.Base(path)), - Handler: createCommandHandler(id, string(content)), + Description: desc, + Handler: createCommandHandler(id, desc, string(content)), }, nil } @@ -155,15 +156,16 @@ func buildCommandID(path, baseDir, prefix string) string { return prefix + strings.Join(parts, ":") } -func createCommandHandler(id string, content string) func(Command) tea.Cmd { +func createCommandHandler(id, desc, content string) func(Command) tea.Cmd { return func(cmd Command) tea.Cmd { args := extractArgNames(content) if len(args) > 0 { return util.CmdHandler(ShowArgumentsDialogMsg{ - CommandID: id, - Content: content, - ArgNames: args, + CommandID: id, + Description: desc, + Content: content, + ArgNames: args, }) } @@ -220,14 +222,9 @@ func LoadMCPPrompts() []Command { continue } clientName, promptName := parts[0], parts[1] - - displayName := promptName - if p.Title != "" { - displayName = p.Title - } - + displayName := clientName + " " + cmp.Or(p.Title, promptName) commands = append(commands, Command{ - ID: MCPPromptPrefix + key, + ID: key, Title: displayName, Description: fmt.Sprintf("[%s] %s", clientName, p.Description), Handler: createMCPPromptHandler(key, promptName, p), diff --git a/internal/tui/components/dialogs/commands/mcp_arguments.go b/internal/tui/components/dialogs/commands/mcp_arguments.go deleted file mode 100644 index 4fd042f38059e57d7c51fa26038f4dcb343ec2ac..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/commands/mcp_arguments.go +++ /dev/null @@ -1,262 +0,0 @@ -package commands - -import ( - "cmp" - "context" - "fmt" - "log/slog" - "strings" - - "github.com/charmbracelet/bubbles/v2/help" - "github.com/charmbracelet/bubbles/v2/key" - "github.com/charmbracelet/bubbles/v2/textinput" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/charmbracelet/crush/internal/llm/agent" - "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const mcpArgumentsDialogID dialogs.DialogID = "mcp_arguments" - -type MCPPromptArgumentsDialog interface { - dialogs.DialogModel -} - -type mcpPromptArgumentsDialogCmp struct { - wWidth, wHeight int - width, height int - selected int - inputs []textinput.Model - keys ArgumentsDialogKeyMap - id string - prompt *mcp.Prompt - help help.Model -} - -func NewMCPPromptArgumentsDialog(id, name string) MCPPromptArgumentsDialog { - id = strings.TrimPrefix(id, MCPPromptPrefix) - prompt, ok := agent.GetMCPPrompt(id) - if !ok { - return nil - } - - t := styles.CurrentTheme() - inputs := make([]textinput.Model, len(prompt.Arguments)) - - for i, arg := range prompt.Arguments { - ti := textinput.New() - placeholder := fmt.Sprintf("Enter value for %s...", arg.Name) - if arg.Description != "" { - placeholder = arg.Description - } - ti.Placeholder = placeholder - ti.SetWidth(40) - ti.SetVirtualCursor(false) - ti.Prompt = "" - ti.SetStyles(t.S().TextInput) - - if i == 0 { - ti.Focus() - } else { - ti.Blur() - } - - inputs[i] = ti - } - - return &mcpPromptArgumentsDialogCmp{ - inputs: inputs, - keys: DefaultArgumentsDialogKeyMap(), - id: id, - prompt: prompt, - help: help.New(), - } -} - -func (c *mcpPromptArgumentsDialogCmp) Init() tea.Cmd { - return nil -} - -func (c *mcpPromptArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - c.wWidth = msg.Width - c.wHeight = msg.Height - cmd := c.SetSize() - return c, cmd - case tea.KeyPressMsg: - switch { - case key.Matches(msg, c.keys.Cancel): - return c, util.CmdHandler(dialogs.CloseDialogMsg{}) - case key.Matches(msg, c.keys.Confirm): - if c.selected == len(c.inputs)-1 { - args := make(map[string]string) - for i, arg := range c.prompt.Arguments { - value := c.inputs[i].Value() - args[arg.Name] = value - } - return c, tea.Sequence( - util.CmdHandler(dialogs.CloseDialogMsg{}), - c.executeMCPPrompt(args), - ) - } - c.inputs[c.selected].Blur() - c.selected++ - c.inputs[c.selected].Focus() - case key.Matches(msg, c.keys.Next): - c.inputs[c.selected].Blur() - c.selected = (c.selected + 1) % len(c.inputs) - c.inputs[c.selected].Focus() - case key.Matches(msg, c.keys.Previous): - c.inputs[c.selected].Blur() - c.selected = (c.selected - 1 + len(c.inputs)) % len(c.inputs) - c.inputs[c.selected].Focus() - default: - var cmd tea.Cmd - c.inputs[c.selected], cmd = c.inputs[c.selected].Update(msg) - return c, cmd - } - } - return c, nil -} - -func (c *mcpPromptArgumentsDialogCmp) executeMCPPrompt(args map[string]string) tea.Cmd { - return func() tea.Msg { - parts := strings.SplitN(c.id, ":", 2) - if len(parts) != 2 { - return util.ReportError(fmt.Errorf("invalid prompt ID: %s", c.id)) - } - clientName := parts[0] - - ctx := context.Background() - slog.Warn("AQUI", "name", c.prompt.Name, "id", c.id) - result, err := agent.GetMCPPromptContent(ctx, clientName, c.prompt.Name, args) - if err != nil { - return util.ReportError(err) - } - - var content strings.Builder - for _, msg := range result.Messages { - if msg.Role == "user" { - if textContent, ok := msg.Content.(*mcp.TextContent); ok { - content.WriteString(textContent.Text) - content.WriteString("\n") - } - } - } - - return chat.SendMsg{ - Text: content.String(), - } - } -} - -func (c *mcpPromptArgumentsDialogCmp) View() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - - title := lipgloss.NewStyle(). - Foreground(t.Primary). - Bold(true). - Padding(0, 1). - Render(cmp.Or(c.prompt.Title, c.prompt.Name)) - - promptName := t.S().Text. - Padding(0, 1). - Render(c.prompt.Description) - - if c.prompt == nil { - return baseStyle.Padding(1, 1, 0, 1). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus). - Width(c.width). - Render(lipgloss.JoinVertical(lipgloss.Left, title, promptName, "", "Prompt not found")) - } - - inputFields := make([]string, len(c.inputs)) - for i, input := range c.inputs { - labelStyle := baseStyle.Padding(1, 1, 0, 1) - - if i == c.selected { - labelStyle = labelStyle.Foreground(t.FgBase).Bold(true) - } else { - labelStyle = labelStyle.Foreground(t.FgMuted) - } - - argName := c.prompt.Arguments[i].Name - if c.prompt.Arguments[i].Required { - argName += " *" - } - label := labelStyle.Render(argName + ":") - - field := t.S().Text. - Padding(0, 1). - Render(input.View()) - - inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field) - } - - elements := []string{title, promptName} - elements = append(elements, inputFields...) - - c.help.ShowAll = false - helpText := baseStyle.Padding(0, 1).Render(c.help.View(c.keys)) - elements = append(elements, "", helpText) - - content := lipgloss.JoinVertical(lipgloss.Left, elements...) - - return baseStyle.Padding(1, 1, 0, 1). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus). - Width(c.width). - Render(content) -} - -func (c *mcpPromptArgumentsDialogCmp) Cursor() *tea.Cursor { - if len(c.inputs) == 0 { - return nil - } - cursor := c.inputs[c.selected].Cursor() - if cursor != nil { - cursor = c.moveCursor(cursor) - } - return cursor -} - -const ( - headerHeight = 3 - itemHeight = 3 - paddingHorizontal = 3 -) - -func (c *mcpPromptArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { - row, col := c.Position() - offset := row + headerHeight + (1+c.selected)*itemHeight - cursor.Y += offset - cursor.X = cursor.X + col + paddingHorizontal - return cursor -} - -func (c *mcpPromptArgumentsDialogCmp) SetSize() tea.Cmd { - c.width = min(90, c.wWidth) - c.height = min(15, c.wHeight) - for i := range c.inputs { - c.inputs[i].SetWidth(c.width - (paddingHorizontal * 2)) - } - return nil -} - -func (c *mcpPromptArgumentsDialogCmp) Position() (int, int) { - row := (c.wHeight / 2) - (c.height / 2) - col := (c.wWidth / 2) - (c.width / 2) - return row, col -} - -func (c *mcpPromptArgumentsDialogCmp) ID() dialogs.DialogID { - return mcpArgumentsDialogID -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 04e640141d91f8e48778bf39ca3f3f4e39102902..f50fa52c0ce299f35505ccf47d012dc5846cb7de 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "log/slog" "math/rand" "strings" "time" @@ -34,6 +35,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" + "github.com/modelcontextprotocol/go-sdk/mcp" ) var lastMouseEvent time.Time @@ -138,21 +140,80 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.dialog = u.(dialogs.DialogCmp) return a, tea.Batch(completionCmd, dialogCmd) case commands.ShowArgumentsDialogMsg: + var args []commands.Argument + for _, arg := range msg.ArgNames { + args = append(args, commands.Argument{Name: arg}) + } return a, util.CmdHandler( dialogs.OpenDialogMsg{ Model: commands.NewCommandArgumentsDialog( msg.CommandID, - msg.Content, - msg.ArgNames, + msg.CommandID, + msg.CommandID, + msg.Description, + args, + func(args map[string]string) tea.Cmd { + return func() tea.Msg { + content := msg.Content + for _, name := range msg.ArgNames { + value := args[name] + placeholder := "$" + name + content = strings.ReplaceAll(content, placeholder, value) + } + return commands.CommandRunCustomMsg{ + Content: content, + } + } + }, ), }, ) case commands.ShowMCPPromptArgumentsDialogMsg: - dialog := commands.NewMCPPromptArgumentsDialog(msg.PromptID, msg.PromptName) - if dialog == nil { + prompt, ok := agent.GetMCPPrompt(msg.PromptID) + if !ok { + slog.Warn("prompt not found", "prompt_id", msg.PromptID, "prompt_name", msg.PromptName) util.ReportWarn(fmt.Sprintf("Prompt %s not found", msg.PromptName)) return a, nil } + args := make([]commands.Argument, 0, len(prompt.Arguments)) + for _, arg := range prompt.Arguments { + args = append(args, commands.Argument(*arg)) + } + dialog := commands.NewCommandArgumentsDialog( + msg.PromptID, + prompt.Title, + prompt.Name, + prompt.Description, + args, + func(args map[string]string) tea.Cmd { + return func() tea.Msg { + parts := strings.SplitN(msg.PromptID, ":", 2) + if len(parts) != 2 { + return util.ReportError(fmt.Errorf("invalid prompt ID: %s", msg.PromptID)) + } + clientName := parts[0] + + ctx := context.Background() + result, err := agent.GetMCPPromptContent(ctx, clientName, prompt.Name, args) + if err != nil { + return util.ReportError(err) + } + + var content strings.Builder + for _, msg := range result.Messages { + if msg.Role == "user" { + if textContent, ok := msg.Content.(*mcp.TextContent); ok { + content.WriteString(textContent.Text) + content.WriteString("\n") + } + } + } + return cmpChat.SendMsg{ + Text: content.String(), + } + } + }, + ) return a, util.CmdHandler( dialogs.OpenDialogMsg{ Model: dialog,