Detailed changes
@@ -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
}
@@ -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),
@@ -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
-}
@@ -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,