Refactor Custom Command Arguments Dialog (#1869)

Kujtim Hoxha created

Change summary

internal/commands/commands.go       |  64 +++-
internal/ui/dialog/actions.go       |  24 -
internal/ui/dialog/api_key_input.go |   8 
internal/ui/dialog/arguments.go     | 399 +++++++++++++++++++++++++++++++
internal/ui/dialog/commands.go      | 106 ++++---
internal/ui/dialog/dialog.go        |  27 ++
internal/ui/model/ui.go             | 133 +++++++++-
internal/ui/styles/styles.go        |  20 +
8 files changed, 684 insertions(+), 97 deletions(-)

Detailed changes

internal/commands/commands.go 🔗

@@ -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
+}

internal/ui/dialog/actions.go 🔗

@@ -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
 	}
 )
 

internal/ui/dialog/api_key_input.go 🔗

@@ -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.

internal/ui/dialog/arguments.go 🔗

@@ -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},
+	}
+}

internal/ui/dialog/commands.go 🔗

@@ -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
+}

internal/ui/dialog/dialog.go 🔗

@@ -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) {

internal/ui/model/ui.go 🔗

@@ -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{

internal/ui/styles/styles.go 🔗

@@ -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