Detailed changes
@@ -1,6 +1,7 @@
package commands
import (
+ "context"
"io/fs"
"os"
"path/filepath"
@@ -19,18 +20,22 @@ const (
projectCommandPrefix = "project:"
)
-// Argument represents a command argument with its name and required status.
+// Argument represents a command argument with its metadata.
type Argument struct {
- Name string
- Required bool
+ ID string
+ Title string
+ Description string
+ Required bool
}
-// MCPCustomCommand represents a custom command loaded from an MCP server.
-type MCPCustomCommand struct {
- ID string
- Name string
- Client string
- Arguments []Argument
+// MCPPrompt represents a custom command loaded from an MCP server.
+type MCPPrompt struct {
+ ID string
+ Title string
+ Description string
+ PromptID string
+ ClientID string
+ Arguments []Argument
}
// CustomCommand represents a user-defined custom command loaded from markdown files.
@@ -52,22 +57,32 @@ func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
return loadAll(buildCommandSources(cfg))
}
-// LoadMCPCustomCommands loads custom commands from available MCP servers.
-func LoadMCPCustomCommands() ([]MCPCustomCommand, error) {
- var commands []MCPCustomCommand
+// LoadMCPPrompts loads custom commands from available MCP servers.
+func LoadMCPPrompts() ([]MCPPrompt, error) {
+ var commands []MCPPrompt
for mcpName, prompts := range mcp.Prompts() {
for _, prompt := range prompts {
key := mcpName + ":" + prompt.Name
var args []Argument
for _, arg := range prompt.Arguments {
- args = append(args, Argument{Name: arg.Name, Required: arg.Required})
+ title := arg.Title
+ if title == "" {
+ title = arg.Name
+ }
+ args = append(args, Argument{
+ ID: arg.Name,
+ Title: title,
+ Description: arg.Description,
+ Required: arg.Required,
+ })
}
-
- commands = append(commands, MCPCustomCommand{
- ID: key,
- Name: prompt.Name,
- Client: mcpName,
- Arguments: args,
+ commands = append(commands, MCPPrompt{
+ ID: key,
+ Title: prompt.Title,
+ Description: prompt.Description,
+ PromptID: prompt.Name,
+ ClientID: mcpName,
+ Arguments: args,
})
}
}
@@ -168,7 +183,7 @@ func extractArgNames(content string) []Argument {
if !seen[arg] {
seen[arg] = true
// for normal custom commands, all args are required
- args = append(args, Argument{Name: arg, Required: true})
+ args = append(args, Argument{ID: arg, Title: arg, Required: true})
}
}
@@ -211,3 +226,12 @@ func ensureDir(path string) error {
func isMarkdownFile(name string) bool {
return strings.HasSuffix(strings.ToLower(name), ".md")
}
+
+func GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
+ // TODO: we should pass the context down
+ result, err := mcp.GetPromptMessages(context.Background(), clientID, promptID, args)
+ if err != nil {
+ return "", err
+ }
+ return strings.Join(result, " "), nil
+}
@@ -51,20 +51,18 @@ type (
}
// ActionRunCustomCommand is a message to run a custom command.
ActionRunCustomCommand struct {
- CommandID string
- // Used when running a user-defined command
- Content string
- // Used when running a prompt from MCP
- Client string
- }
- // ActionOpenCustomCommandArgumentsDialog is a message to open the custom command arguments dialog.
- ActionOpenCustomCommandArgumentsDialog struct {
- CommandID string
- // Used when running a user-defined command
- Content string
- // Used when running a prompt from MCP
- Client string
+ Content string
Arguments []commands.Argument
+ Args map[string]string // Actual argument values
+ }
+ // ActionRunMCPPrompt is a message to run a custom command.
+ ActionRunMCPPrompt struct {
+ Title string
+ Description string
+ PromptID string
+ ClientID string
+ Arguments []commands.Argument
+ Args map[string]string // Actual argument values
}
)
@@ -95,7 +95,7 @@ func (m *APIKeyInput) ID() string {
return APIKeyInputID
}
-// Update implements tea.Model.
+// HandleMsg implements [Dialog].
func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action {
switch msg := msg.(type) {
case ActionChangeAPIKeyState:
@@ -149,7 +149,7 @@ func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action {
return nil
}
-// View implements tea.Model.
+// Draw implements [Dialog].
func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
t := m.com.Styles
@@ -239,8 +239,8 @@ func (m *APIKeyInput) inputView() string {
}
// Cursor returns the cursor position relative to the dialog.
-func (c *APIKeyInput) Cursor() *tea.Cursor {
- return InputCursor(c.com.Styles, c.input.Cursor())
+func (m *APIKeyInput) Cursor() *tea.Cursor {
+ return InputCursor(m.com.Styles, m.input.Cursor())
}
// FullHelp returns the full help view.
@@ -0,0 +1,399 @@
+package dialog
+
+import (
+ "strings"
+
+ "charm.land/bubbles/v2/help"
+ "charm.land/bubbles/v2/key"
+ "charm.land/bubbles/v2/spinner"
+ "charm.land/bubbles/v2/textinput"
+ "charm.land/bubbles/v2/viewport"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
+
+ "github.com/charmbracelet/crush/internal/commands"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/uiutil"
+ uv "github.com/charmbracelet/ultraviolet"
+)
+
+// ArgumentsID is the identifier for the arguments dialog.
+const ArgumentsID = "arguments"
+
+// Dialog sizing for arguments.
+const (
+ maxInputWidth = 120
+ minInputWidth = 30
+ maxViewportHeight = 20
+ argumentsFieldHeight = 3 // label + input + spacing per field
+)
+
+// Arguments represents a dialog for collecting command arguments.
+type Arguments struct {
+ com *common.Common
+ title string
+ arguments []commands.Argument
+ inputs []textinput.Model
+ focused int
+ spinner spinner.Model
+ loading bool
+
+ description string
+ resultAction Action
+
+ help help.Model
+ keyMap struct {
+ Confirm,
+ Next,
+ Previous,
+ ScrollUp,
+ ScrollDown,
+ Close key.Binding
+ }
+
+ viewport viewport.Model
+}
+
+var _ Dialog = (*Arguments)(nil)
+
+// NewArguments creates a new arguments dialog.
+func NewArguments(com *common.Common, title, description string, arguments []commands.Argument, resultAction Action) *Arguments {
+ a := &Arguments{
+ com: com,
+ title: title,
+ description: description,
+ arguments: arguments,
+ resultAction: resultAction,
+ }
+
+ a.help = help.New()
+ a.help.Styles = com.Styles.DialogHelpStyles()
+
+ a.keyMap.Confirm = key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "confirm"),
+ )
+ a.keyMap.Next = key.NewBinding(
+ key.WithKeys("down", "tab"),
+ key.WithHelp("↓/tab", "next"),
+ )
+ a.keyMap.Previous = key.NewBinding(
+ key.WithKeys("up", "shift+tab"),
+ key.WithHelp("↑/shift+tab", "previous"),
+ )
+ a.keyMap.Close = CloseKey
+
+ // Create input fields for each argument.
+ a.inputs = make([]textinput.Model, len(arguments))
+ for i, arg := range arguments {
+ input := textinput.New()
+ input.SetVirtualCursor(false)
+ input.SetStyles(com.Styles.TextInput)
+ input.Prompt = "> "
+ // Use description as placeholder if available, otherwise title
+ if arg.Description != "" {
+ input.Placeholder = arg.Description
+ } else {
+ input.Placeholder = arg.Title
+ }
+
+ if i == 0 {
+ input.Focus()
+ } else {
+ input.Blur()
+ }
+
+ a.inputs[i] = input
+ }
+ s := spinner.New()
+ s.Spinner = spinner.Dot
+ s.Style = com.Styles.Dialog.Spinner
+ a.spinner = s
+
+ return a
+}
+
+// ID implements Dialog.
+func (a *Arguments) ID() string {
+ return ArgumentsID
+}
+
+// focusInput changes focus to a new input by index with wrap-around.
+func (a *Arguments) focusInput(newIndex int) {
+ a.inputs[a.focused].Blur()
+
+ // Wrap around: Go's modulo can return negative, so add len first.
+ n := len(a.inputs)
+ a.focused = ((newIndex % n) + n) % n
+
+ a.inputs[a.focused].Focus()
+
+ // Ensure the newly focused field is visible in the viewport
+ a.ensureFieldVisible(a.focused)
+}
+
+// isFieldVisible checks if a field at the given index is visible in the viewport.
+func (a *Arguments) isFieldVisible(fieldIndex int) bool {
+ fieldStart := fieldIndex * argumentsFieldHeight
+ fieldEnd := fieldStart + argumentsFieldHeight - 1
+ viewportTop := a.viewport.YOffset()
+ viewportBottom := viewportTop + a.viewport.Height() - 1
+
+ return fieldStart >= viewportTop && fieldEnd <= viewportBottom
+}
+
+// ensureFieldVisible scrolls the viewport to make the field visible.
+func (a *Arguments) ensureFieldVisible(fieldIndex int) {
+ if a.isFieldVisible(fieldIndex) {
+ return
+ }
+
+ fieldStart := fieldIndex * argumentsFieldHeight
+ fieldEnd := fieldStart + argumentsFieldHeight - 1
+ viewportTop := a.viewport.YOffset()
+ viewportHeight := a.viewport.Height()
+
+ // If field is above viewport, scroll up to show it at top
+ if fieldStart < viewportTop {
+ a.viewport.SetYOffset(fieldStart)
+ return
+ }
+
+ // If field is below viewport, scroll down to show it at bottom
+ if fieldEnd > viewportTop+viewportHeight-1 {
+ a.viewport.SetYOffset(fieldEnd - viewportHeight + 1)
+ }
+}
+
+// findVisibleFieldByOffset returns the field index closest to the given viewport offset.
+func (a *Arguments) findVisibleFieldByOffset(fromTop bool) int {
+ offset := a.viewport.YOffset()
+ if !fromTop {
+ offset += a.viewport.Height() - 1
+ }
+
+ fieldIndex := offset / argumentsFieldHeight
+ if fieldIndex >= len(a.inputs) {
+ return len(a.inputs) - 1
+ }
+ return fieldIndex
+}
+
+// HandleMsg implements Dialog.
+func (a *Arguments) HandleMsg(msg tea.Msg) Action {
+ switch msg := msg.(type) {
+ case spinner.TickMsg:
+ if a.loading {
+ var cmd tea.Cmd
+ a.spinner, cmd = a.spinner.Update(msg)
+ return ActionCmd{Cmd: cmd}
+ }
+ case tea.KeyPressMsg:
+ switch {
+ case key.Matches(msg, a.keyMap.Close):
+ return ActionClose{}
+ case key.Matches(msg, a.keyMap.Confirm):
+ // If we're on the last input or there's only one input, submit.
+ if a.focused == len(a.inputs)-1 || len(a.inputs) == 1 {
+ args := make(map[string]string)
+ var warning tea.Cmd
+ for i, arg := range a.arguments {
+ args[arg.ID] = a.inputs[i].Value()
+ if arg.Required && strings.TrimSpace(a.inputs[i].Value()) == "" {
+ warning = uiutil.ReportWarn("Required argument '" + arg.Title + "' is missing.")
+ break
+ }
+ }
+ if warning != nil {
+ return ActionCmd{Cmd: warning}
+ }
+
+ switch action := a.resultAction.(type) {
+ case ActionRunCustomCommand:
+ action.Args = args
+ return action
+ case ActionRunMCPPrompt:
+ action.Args = args
+ return action
+ }
+ }
+ a.focusInput(a.focused + 1)
+ case key.Matches(msg, a.keyMap.Next):
+ a.focusInput(a.focused + 1)
+ case key.Matches(msg, a.keyMap.Previous):
+ a.focusInput(a.focused - 1)
+ default:
+ var cmd tea.Cmd
+ a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
+ return ActionCmd{Cmd: cmd}
+ }
+ case tea.MouseWheelMsg:
+ a.viewport, _ = a.viewport.Update(msg)
+ // If focused field scrolled out of view, focus the visible field
+ if !a.isFieldVisible(a.focused) {
+ a.focusInput(a.findVisibleFieldByOffset(msg.Button == tea.MouseWheelDown))
+ }
+ case tea.PasteMsg:
+ var cmd tea.Cmd
+ a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
+ return ActionCmd{Cmd: cmd}
+ }
+ return nil
+}
+
+// Cursor returns the cursor position relative to the dialog.
+// we pass the description height to offset the cursor correctly.
+func (a *Arguments) Cursor(descriptionHeight int) *tea.Cursor {
+ cursor := InputCursor(a.com.Styles, a.inputs[a.focused].Cursor())
+ if cursor == nil {
+ return nil
+ }
+ cursor.Y += descriptionHeight + a.focused*argumentsFieldHeight - a.viewport.YOffset() + 1
+ return cursor
+}
+
+// Draw implements Dialog.
+func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ s := a.com.Styles
+
+ dialogContentStyle := s.Dialog.Arguments.Content
+ possibleWidth := area.Dx() - s.Dialog.View.GetHorizontalFrameSize() - dialogContentStyle.GetHorizontalFrameSize()
+ // Build fields with label and input.
+ caser := cases.Title(language.English)
+
+ var fields []string
+ for i, arg := range a.arguments {
+ isFocused := i == a.focused
+
+ // Try to pretty up the title for the label.
+ title := strings.ReplaceAll(arg.Title, "_", " ")
+ title = strings.ReplaceAll(title, "-", " ")
+ titleParts := strings.Fields(title)
+ for i, part := range titleParts {
+ titleParts[i] = caser.String(strings.ToLower(part))
+ }
+ labelText := strings.Join(titleParts, " ")
+
+ markRequiredStyle := s.Dialog.Arguments.InputRequiredMarkBlurred
+
+ labelStyle := s.Dialog.Arguments.InputLabelBlurred
+ if isFocused {
+ labelStyle = s.Dialog.Arguments.InputLabelFocused
+ markRequiredStyle = s.Dialog.Arguments.InputRequiredMarkFocused
+ }
+ if arg.Required {
+ labelText += markRequiredStyle.String()
+ }
+ label := labelStyle.Render(labelText)
+
+ labelWidth := lipgloss.Width(labelText)
+ placeholderWidth := lipgloss.Width(a.inputs[i].Placeholder)
+
+ inputWidth := max(placeholderWidth, labelWidth, minInputWidth)
+ inputWidth = min(inputWidth, min(possibleWidth, maxInputWidth))
+ a.inputs[i].SetWidth(inputWidth)
+
+ inputLine := a.inputs[i].View()
+
+ field := lipgloss.JoinVertical(lipgloss.Left, label, inputLine, "")
+ fields = append(fields, field)
+ }
+
+ renderedFields := lipgloss.JoinVertical(lipgloss.Left, fields...)
+
+ // Anchor width to the longest field, capped at maxInputWidth.
+ const scrollbarWidth = 1
+ width := lipgloss.Width(renderedFields)
+ height := lipgloss.Height(renderedFields)
+
+ // Use standard header
+ titleStyle := s.Dialog.Title
+
+ titleText := a.title
+ if titleText == "" {
+ titleText = "Arguments"
+ }
+
+ header := common.DialogTitle(s, titleText, width)
+
+ // Add description if available.
+ var description string
+ if a.description != "" {
+ descStyle := s.Dialog.Arguments.Description.Width(width)
+ description = descStyle.Render(a.description)
+ }
+
+ helpView := s.Dialog.HelpView.Width(width).Render(a.help.View(a))
+ if a.loading {
+ helpView = s.Dialog.HelpView.Width(width).Render(a.spinner.View() + " Generating Prompt...")
+ }
+
+ availableHeight := area.Dy() - s.Dialog.View.GetVerticalFrameSize() - dialogContentStyle.GetVerticalFrameSize() - lipgloss.Height(header) - lipgloss.Height(description) - lipgloss.Height(helpView) - 2 // extra spacing
+ viewportHeight := min(height, maxViewportHeight, availableHeight)
+
+ a.viewport.SetWidth(width) // -1 for scrollbar
+ a.viewport.SetHeight(viewportHeight)
+ a.viewport.SetContent(renderedFields)
+
+ scrollbar := common.Scrollbar(s, viewportHeight, a.viewport.TotalLineCount(), viewportHeight, a.viewport.YOffset())
+ content := a.viewport.View()
+ if scrollbar != "" {
+ content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
+ }
+ contentParts := []string{}
+ if description != "" {
+ contentParts = append(contentParts, description)
+ }
+ contentParts = append(contentParts, content)
+
+ view := lipgloss.JoinVertical(
+ lipgloss.Left,
+ titleStyle.Render(header),
+ dialogContentStyle.Render(lipgloss.JoinVertical(lipgloss.Left, contentParts...)),
+ helpView,
+ )
+
+ dialog := s.Dialog.View.Render(view)
+
+ descriptionHeight := 0
+ if a.description != "" {
+ descriptionHeight = lipgloss.Height(description)
+ }
+ cur := a.Cursor(descriptionHeight)
+
+ DrawCenterCursor(scr, area, dialog, cur)
+ return cur
+}
+
+// StartLoading implements [LoadingDialog].
+func (a *Arguments) StartLoading() tea.Cmd {
+ if a.loading {
+ return nil
+ }
+ a.loading = true
+ return a.spinner.Tick
+}
+
+// StopLoading implements [LoadingDialog].
+func (a *Arguments) StopLoading() {
+ a.loading = false
+}
+
+// ShortHelp implements help.KeyMap.
+func (a *Arguments) ShortHelp() []key.Binding {
+ return []key.Binding{
+ a.keyMap.Confirm,
+ a.keyMap.Next,
+ a.keyMap.Close,
+ }
+}
+
+// FullHelp implements help.KeyMap.
+func (a *Arguments) FullHelp() [][]key.Binding {
+ return [][]key.Binding{
+ {a.keyMap.Confirm, a.keyMap.Next, a.keyMap.Previous},
+ {a.keyMap.Close},
+ }
+}
@@ -6,6 +6,7 @@ import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
+ "charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
@@ -52,26 +53,29 @@ type Commands struct {
sessionID string // can be empty for non-session-specific commands
selected CommandType
+ spinner spinner.Model
+ loading bool
+
help help.Model
input textinput.Model
list *list.FilterableList
windowWidth int
- customCommands []commands.CustomCommand
- mcpCustomCommands []commands.MCPCustomCommand
+ customCommands []commands.CustomCommand
+ mcpPrompts []commands.MCPPrompt
}
var _ Dialog = (*Commands)(nil)
// NewCommands creates a new commands dialog.
-func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpCustomCommands []commands.MCPCustomCommand) (*Commands, error) {
+func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) {
c := &Commands{
- com: com,
- selected: SystemCommands,
- sessionID: sessionID,
- customCommands: customCommands,
- mcpCustomCommands: mcpCustomCommands,
+ com: com,
+ selected: SystemCommands,
+ sessionID: sessionID,
+ customCommands: customCommands,
+ mcpPrompts: mcpPrompts,
}
help := help.New()
@@ -120,6 +124,11 @@ func NewCommands(com *common.Common, sessionID string, customCommands []commands
// Set initial commands
c.setCommandItems(c.selected)
+ s := spinner.New()
+ s.Spinner = spinner.Dot
+ s.Style = com.Styles.Dialog.Spinner
+ c.spinner = s
+
return c, nil
}
@@ -128,9 +137,15 @@ func (c *Commands) ID() string {
return CommandsID
}
-// HandleMsg implements Dialog.
+// HandleMsg implements [Dialog].
func (c *Commands) HandleMsg(msg tea.Msg) Action {
switch msg := msg.(type) {
+ case spinner.TickMsg:
+ if c.loading {
+ var cmd tea.Cmd
+ c.spinner, cmd = c.spinner.Update(msg)
+ return ActionCmd{Cmd: cmd}
+ }
case tea.KeyPressMsg:
switch {
case key.Matches(msg, c.keyMap.Close):
@@ -160,12 +175,12 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
}
}
case key.Matches(msg, c.keyMap.Tab):
- if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 {
+ if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
c.selected = c.nextCommandType()
c.setCommandItems(c.selected)
}
case key.Matches(msg, c.keyMap.ShiftTab):
- if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 {
+ if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
c.selected = c.previousCommandType()
c.setCommandItems(c.selected)
}
@@ -242,12 +257,16 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
c.list.SetSize(innerWidth, height-heightOffset)
c.help.SetWidth(innerWidth)
- radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpCustomCommands) > 0)
+ radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
titleStyle := t.Dialog.Title
dialogStyle := t.Dialog.View.Width(width)
headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
helpView := ansi.Truncate(c.help.View(c), innerWidth, "")
header := common.DialogTitle(t, "Commands", width-headerOffset) + radio
+
+ if c.loading {
+ helpView = t.Dialog.HelpView.Width(width).Render(c.spinner.View() + " Generating Prompt...")
+ }
view := HeaderInputListHelpView(t, width, c.list.Height(), header,
c.input.View(), c.list.Render(), helpView)
@@ -281,12 +300,12 @@ func (c *Commands) nextCommandType() CommandType {
if len(c.customCommands) > 0 {
return UserCommands
}
- if len(c.mcpCustomCommands) > 0 {
+ if len(c.mcpPrompts) > 0 {
return MCPPrompts
}
fallthrough
case UserCommands:
- if len(c.mcpCustomCommands) > 0 {
+ if len(c.mcpPrompts) > 0 {
return MCPPrompts
}
fallthrough
@@ -301,7 +320,7 @@ func (c *Commands) nextCommandType() CommandType {
func (c *Commands) previousCommandType() CommandType {
switch c.selected {
case SystemCommands:
- if len(c.mcpCustomCommands) > 0 {
+ if len(c.mcpPrompts) > 0 {
return MCPPrompts
}
if len(c.customCommands) > 0 {
@@ -332,37 +351,22 @@ func (c *Commands) setCommandItems(commandType CommandType) {
}
case UserCommands:
for _, cmd := range c.customCommands {
- var action Action
- if len(cmd.Arguments) > 0 {
- action = ActionOpenCustomCommandArgumentsDialog{
- CommandID: cmd.ID,
- Content: cmd.Content,
- Arguments: cmd.Arguments,
- }
- } else {
- action = ActionRunCustomCommand{
- CommandID: cmd.ID,
- Content: cmd.Content,
- }
+ action := ActionRunCustomCommand{
+ Content: cmd.Content,
+ Arguments: cmd.Arguments,
}
commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
}
case MCPPrompts:
- for _, cmd := range c.mcpCustomCommands {
- var action Action
- if len(cmd.Arguments) > 0 {
- action = ActionOpenCustomCommandArgumentsDialog{
- CommandID: cmd.ID,
- Client: cmd.Client,
- Arguments: cmd.Arguments,
- }
- } else {
- action = ActionRunCustomCommand{
- CommandID: cmd.ID,
- Client: cmd.Client,
- }
+ for _, cmd := range c.mcpPrompts {
+ action := ActionRunMCPPrompt{
+ Title: cmd.Title,
+ Description: cmd.Description,
+ PromptID: cmd.PromptID,
+ ClientID: cmd.ClientID,
+ Arguments: cmd.Arguments,
}
- commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.Name, "", action))
+ commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action))
}
}
@@ -448,10 +452,24 @@ func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
}
}
-// SetMCPCustomCommands sets the MCP custom commands and refreshes the view if MCP prompts are currently displayed.
-func (c *Commands) SetMCPCustomCommands(mcpCustomCommands []commands.MCPCustomCommand) {
- c.mcpCustomCommands = mcpCustomCommands
+// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed.
+func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) {
+ c.mcpPrompts = mcpPrompts
if c.selected == MCPPrompts {
c.setCommandItems(c.selected)
}
}
+
+// StartLoading implements [LoadingDialog].
+func (a *Commands) StartLoading() tea.Cmd {
+ if a.loading {
+ return nil
+ }
+ a.loading = true
+ return a.spinner.Tick
+}
+
+// StopLoading implements [LoadingDialog].
+func (a *Commands) StopLoading() {
+ a.loading = false
+}
@@ -27,7 +27,7 @@ var CloseKey = key.NewBinding(
)
// Action represents an action taken in a dialog after handling a message.
-type Action interface{}
+type Action any
// Dialog is a component that can be displayed on top of the UI.
type Dialog interface {
@@ -41,6 +41,12 @@ type Dialog interface {
Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
}
+// LoadingDialog is a dialog that can show a loading state.
+type LoadingDialog interface {
+ StartLoading() tea.Cmd
+ StopLoading()
+}
+
// Overlay manages multiple dialogs as an overlay.
type Overlay struct {
dialogs []Dialog
@@ -136,6 +142,25 @@ func (d *Overlay) Update(msg tea.Msg) tea.Msg {
return dialog.HandleMsg(msg)
}
+// StartLoading starts the loading state for the front dialog if it
+// implements [LoadingDialog].
+func (d *Overlay) StartLoading() tea.Cmd {
+ dialog := d.DialogLast()
+ if ld, ok := dialog.(LoadingDialog); ok {
+ return ld.StartLoading()
+ }
+ return nil
+}
+
+// StopLoading stops the loading state for the front dialog if it
+// implements [LoadingDialog].
+func (d *Overlay) StopLoading() {
+ dialog := d.DialogLast()
+ if ld, ok := dialog.(LoadingDialog); ok {
+ ld.StopLoading()
+ }
+}
+
// DrawCenterCursor draws the given string view centered in the screen area and
// adjusts the cursor position accordingly.
func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
@@ -18,6 +18,7 @@ import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
+ "charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
@@ -93,10 +94,19 @@ type (
userCommandsLoadedMsg struct {
Commands []commands.CustomCommand
}
- // mcpCustomCommandsLoadedMsg is sent when mcp prompts are loaded.
- mcpCustomCommandsLoadedMsg struct {
- Prompts []commands.MCPCustomCommand
+ // mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
+ mcpPromptsLoadedMsg struct {
+ Prompts []commands.MCPPrompt
}
+ // sendMessageMsg is sent to send a message.
+ // currently only used for mcp prompts.
+ sendMessageMsg struct {
+ Content string
+ Attachments []message.Attachment
+ }
+
+ // closeDialogMsg is sent to close the current dialog.
+ closeDialogMsg struct{}
)
// UI represents the main user interface model.
@@ -167,8 +177,8 @@ type UI struct {
sidebarLogo string
// custom commands & mcp commands
- customCommands []commands.CustomCommand
- mcpCustomCommands []commands.MCPCustomCommand
+ customCommands []commands.CustomCommand
+ mcpPrompts []commands.MCPPrompt
// forceCompactMode tracks whether compact mode is forced by user toggle
forceCompactMode bool
@@ -282,15 +292,15 @@ func (m *UI) loadCustomCommands() tea.Cmd {
// loadMCPrompts loads the MCP prompts asynchronously.
func (m *UI) loadMCPrompts() tea.Cmd {
return func() tea.Msg {
- prompts, err := commands.LoadMCPCustomCommands()
+ prompts, err := commands.LoadMCPPrompts()
if err != nil {
slog.Error("failed to load mcp prompts", "error", err)
}
if prompts == nil {
// flag them as loaded even if there is none or an error
- prompts = []commands.MCPCustomCommand{}
+ prompts = []commands.MCPPrompt{}
}
- return mcpCustomCommandsLoadedMsg{Prompts: prompts}
+ return mcpPromptsLoadedMsg{Prompts: prompts}
}
}
@@ -319,6 +329,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
}
+ case sendMessageMsg:
+ cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
+
case userCommandsLoadedMsg:
m.customCommands = msg.Commands
dia := m.dialog.Dialog(dialog.CommandsID)
@@ -330,8 +343,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if ok {
commands.SetCustomCommands(m.customCommands)
}
- case mcpCustomCommandsLoadedMsg:
- m.mcpCustomCommands = msg.Prompts
+ case mcpPromptsLoadedMsg:
+ m.mcpPrompts = msg.Prompts
dia := m.dialog.Dialog(dialog.CommandsID)
if dia == nil {
break
@@ -339,9 +352,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
commands, ok := dia.(*dialog.Commands)
if ok {
- commands.SetMCPCustomCommands(m.mcpCustomCommands)
+ commands.SetMCPPrompts(m.mcpPrompts)
}
+ case closeDialogMsg:
+ m.dialog.CloseFrontDialog()
+
case pubsub.Event[message.Message]:
// Check if this is a child session message for an agent tool.
if m.session == nil {
@@ -374,7 +390,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
break
}
}
- if initialized && m.mcpCustomCommands == nil {
+ if initialized && m.mcpPrompts == nil {
cmds = append(cmds, m.loadMCPrompts())
}
case pubsub.Event[permission.PermissionRequest]:
@@ -492,6 +508,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
}
}
+ case spinner.TickMsg:
+ if m.dialog.HasDialogs() {
+ // route to dialog
+ if cmd := m.handleDialogMsg(msg); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+
case tea.KeyPressMsg:
if cmd := m.handleKeyPressMsg(msg); cmd != nil {
cmds = append(cmds, cmd)
@@ -645,6 +669,11 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
// if the message is a tool result it will update the corresponding tool call message
func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
var cmds []tea.Cmd
+ existing := m.chat.MessageItem(msg.ID)
+ if existing != nil {
+ // message already exists, skip
+ return nil
+ }
switch msg.Role {
case message.User, message.Assistant:
items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
@@ -920,6 +949,44 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
case dialog.PermissionDeny:
m.com.App.Permissions.Deny(msg.Permission)
}
+
+ case dialog.ActionRunCustomCommand:
+ if len(msg.Arguments) > 0 && msg.Args == nil {
+ m.dialog.CloseFrontDialog()
+ argsDialog := dialog.NewArguments(
+ m.com,
+ "Custom Command Arguments",
+ "",
+ msg.Arguments,
+ msg, // Pass the action as the result
+ )
+ m.dialog.OpenDialog(argsDialog)
+ break
+ }
+ content := msg.Content
+ if msg.Args != nil {
+ content = substituteArgs(content, msg.Args)
+ }
+ cmds = append(cmds, m.sendMessage(content))
+ m.dialog.CloseFrontDialog()
+ case dialog.ActionRunMCPPrompt:
+ if len(msg.Arguments) > 0 && msg.Args == nil {
+ m.dialog.CloseFrontDialog()
+ title := msg.Title
+ if title == "" {
+ title = "MCP Prompt Arguments"
+ }
+ argsDialog := dialog.NewArguments(
+ m.com,
+ title,
+ msg.Description,
+ msg.Arguments,
+ msg, // Pass the action as the result
+ )
+ m.dialog.OpenDialog(argsDialog)
+ break
+ }
+ cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
default:
cmds = append(cmds, uiutil.CmdHandler(msg))
}
@@ -927,6 +994,15 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
return tea.Batch(cmds...)
}
+// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
+func substituteArgs(content string, args map[string]string) string {
+ for name, value := range args {
+ placeholder := "$" + name
+ content = strings.ReplaceAll(content, placeholder, value)
+ }
+ return content
+}
+
// openAPIKeyInputDialog opens the API key input dialog.
func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
@@ -1055,7 +1131,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
m.randomizePlaceholders()
- return m.sendMessage(value, attachments)
+ return m.sendMessage(value, attachments...)
case key.Matches(msg, m.keyMap.Chat.NewSession):
if m.session == nil || m.session.ID == "" {
break
@@ -2013,7 +2089,7 @@ func (m *UI) renderSidebarLogo(width int) {
}
// sendMessage sends a message with the given content and attachments.
-func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
+func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
if m.com.App.AgentCoordinator == nil {
return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
}
@@ -2165,7 +2241,7 @@ func (m *UI) openCommandsDialog() tea.Cmd {
sessionID = m.session.ID
}
- commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpCustomCommands)
+ commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
if err != nil {
return uiutil.ReportError(err)
}
@@ -2393,6 +2469,33 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
).Draw(scr, area)
}
+func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
+ load := func() tea.Msg {
+ prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
+ if err != nil {
+ // TODO: make this better
+ return uiutil.ReportError(err)()
+ }
+
+ if prompt == "" {
+ return nil
+ }
+ return sendMessageMsg{
+ Content: prompt,
+ }
+ }
+
+ var cmds []tea.Cmd
+ if cmd := m.dialog.StartLoading(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ cmds = append(cmds, load, func() tea.Msg {
+ return closeDialogMsg{}
+ })
+
+ return tea.Sequence(cmds...)
+}
+
// renderLogo renders the Crush logo with the given styles and dimensions.
func renderLogo(t *styles.Styles, compact bool, width int) string {
return logo.Render(version.Version, compact, logo.Opts{
@@ -333,6 +333,8 @@ type Styles struct {
List lipgloss.Style
+ Spinner lipgloss.Style
+
// ContentPanel is used for content blocks with subtle background.
ContentPanel lipgloss.Style
@@ -340,6 +342,16 @@ type Styles struct {
ScrollbarThumb lipgloss.Style
ScrollbarTrack lipgloss.Style
+ // Arguments
+ Arguments struct {
+ Content lipgloss.Style
+ Description lipgloss.Style
+ InputLabelBlurred lipgloss.Style
+ InputLabelFocused lipgloss.Style
+ InputRequiredMarkBlurred lipgloss.Style
+ InputRequiredMarkFocused lipgloss.Style
+ }
+
Commands struct{}
}
@@ -1205,9 +1217,17 @@ func DefaultStyles() Styles {
s.Dialog.List = base.Margin(0, 0, 1, 0)
s.Dialog.ContentPanel = base.Background(bgSubtle).Foreground(fgBase).Padding(1, 2)
+ s.Dialog.Spinner = base.Foreground(secondary)
s.Dialog.ScrollbarThumb = base.Foreground(secondary)
s.Dialog.ScrollbarTrack = base.Foreground(border)
+ s.Dialog.Arguments.Content = base.Padding(1)
+ s.Dialog.Arguments.Description = base.MarginBottom(1).MaxHeight(3)
+ s.Dialog.Arguments.InputLabelBlurred = base.Foreground(fgMuted)
+ s.Dialog.Arguments.InputLabelFocused = base.Bold(true)
+ s.Dialog.Arguments.InputRequiredMarkBlurred = base.Foreground(fgMuted).SetString("*")
+ s.Dialog.Arguments.InputRequiredMarkFocused = base.Foreground(primary).Bold(true).SetString("*")
+
s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
s.Status.InfoIndicator = s.Status.SuccessIndicator